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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions