Description
return, break and continue statements inside try blocks in async functions don't preserve TypeScript semantics:
return inside try — code after the try/catch block still executes (should be unreachable)
break inside try in a loop — Lua load-time error: <break> not inside a loop
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
Description
return,breakandcontinuestatements insidetryblocks inasyncfunctions don't preserve TypeScript semantics:returninsidetry— code after thetry/catchblock still executes (should be unreachable)breakinsidetryin a loop — Lua load-time error:<break> not inside a loopcontinueinsidetryin a loop — Lua load-time error:no visible labelReproduction:
returninsidetryExpected:
print('unreachable!')is dead code — bothtryandcatchbranches return.Actual:
print('unreachable!')executes.Note: the
returnissue only manifests when the awaited promise resolves asynchronously. With synchronously resolved promises (Promise.resolve()) the bug does not reproduce.returnin a loop (same deferred promise issue — exits only the nested__TS__AsyncAwaiter, loop continues):Reproduction:
breakandcontinueinsidetryin a loopbreak:Expected: breaks out of the loop.
Actual: Lua load-time error:
<break> at line ... not inside a loopcontinue:Expected: prints
0and2, skips1.Actual: Lua load-time error:
no visible label '__continue' for <goto>Root cause
TSTL compiles
tryinto a nested__TS__AsyncAwaiter(simplified):return ____awaiter_resolve(...)resolves the outer function's promise but only exits the inner__TS__AsyncAwaitercoroutine — it does not terminate the outer async function__TS__Await(____try.catch(...))the outer function continues executing (resolve + continue)break/continueare emitted inside the inner coroutine function where no enclosing loop or label exists, producing invalid Lua at load timeWorkaround
Avoid
return,breakandcontinueinsidetry/catchblocks in async functions. Use variable assignment insidetry/catch, then control flow after the block:Environment