Skip to content

return, break and continue inside try in async functions don't work correctly #1706

@denis-atsuta

Description

@denis-atsuta

Description

return, break and continue statements inside try blocks in async functions don't preserve TypeScript semantics:

  1. return inside try — code after the try/catch block still executes (should be unreachable)
  2. break inside try in a loop — Lua load-time error: <break> not inside a loop
  3. continue inside try in a loop — Lua load-time error: no visible label

Reproduction: return inside try

let resolveLater!: (value: string) => void;

function deferredPromise(): Promise<string> {
    return new Promise(resolve => {
        resolveLater = (v) => resolve(v);
    });
}

async function fn(): Promise<string> {
    try {
        return await deferredPromise();
    } catch {
        return 'caught';
    }
    print('unreachable!');
}

(async () => {
    const promise = fn();
    resolveLater('ok');
    print(await promise);
})();

Expected: print('unreachable!') is dead code — both try and catch branches return.

Actual: print('unreachable!') executes.

Note: the return issue only manifests when the awaited promise resolves asynchronously. With synchronously resolved promises (Promise.resolve()) the bug does not reproduce.

return in a loop (same deferred promise issue — exits only the nested __TS__AsyncAwaiter, loop continues):

async function fn(): Promise<string> {
    while (true) {
        try {
            return await deferredPromise();
        } catch {
            return 'caught';
        }
        print('unreachable!');
    }
}

Reproduction: break and continue inside try in a loop

break:

async function fn(): Promise<void> {
    while (true) {
        try {
            await Promise.resolve();
            break;
        } catch {}
    }
}

Expected: breaks out of the loop.
Actual: Lua load-time error: <break> at line ... not inside a loop

continue:

async function fn(): Promise<void> {
    for (let i = 0; i < 3; i++) {
        try {
            await Promise.resolve();
            if (i === 1) continue;
        } catch {}
        print(i);
    }
}

Expected: prints 0 and 2, skips 1.
Actual: Lua load-time error: no visible label '__continue' for <goto>

Root cause

TSTL compiles try into a nested __TS__AsyncAwaiter (simplified):

local ____try = __TS__AsyncAwaiter(function()
    return ____awaiter_resolve(nil, __TS__Await(deferredPromise()))
end)
__TS__Await(____try.catch(____try, function(____)
    return ____awaiter_resolve(nil, "caught")
end))
-- code below executes unconditionally after ____try completes
print("unreachable!")
  • return ____awaiter_resolve(...) resolves the outer function's promise but only exits the inner __TS__AsyncAwaiter coroutine — it does not terminate the outer async function
  • After __TS__Await(____try.catch(...)) the outer function continues executing (resolve + continue)
  • break/continue are emitted inside the inner coroutine function where no enclosing loop or label exists, producing invalid Lua at load time

Workaround

Avoid return, break and continue inside try/catch blocks in async functions. Use variable assignment inside try/catch, then control flow after the block:

// return workaround
async function fn(): Promise<string> {
    let result: string;
    try {
        result = await deferredPromise();
    } catch {
        result = 'caught';
    }
    return result;
}

// break workaround
async function fn2(): Promise<void> {
    let done = false;
    while (!done) {
        try {
            await doWork();
            done = true;
        } catch {}
    }
}

Environment

  • TSTL version: 1.34.0
  • Lua target: 5.4

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions