Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/LuaLib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export enum LuaLibFeature {
DescriptorGet = "DescriptorGet",
DescriptorSet = "DescriptorSet",
Error = "Error",
ErrorObject = "ErrorObject",
FunctionBind = "FunctionBind",
Generator = "Generator",
InstanceOf = "InstanceOf",
Expand Down
18 changes: 18 additions & 0 deletions src/lualib/ErrorObject.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Lua 5.5 replaces a `nil` error object with a string ("<no error object>")
// when an error propagates out of a protected call. This breaks `throw undefined`
// round-tripping through `pcall`. To preserve the original value, we route
// protected calls through `xpcall` with a message handler that wraps nil into
// this sentinel table (Lua 5.5 only mangles nil, not other values), and unwrap
// on the catch side.
// See: https://www.lua.org/manual/5.5/manual.html#8.1
const ____TS__NilErrorObject: any = {};

export function __TS__WrapErrorObject(this: void, value: any): any {
if (value === undefined) return ____TS__NilErrorObject;
return value;
}

export function __TS__UnwrapErrorObject(this: void, value: any): any {
if (value === ____TS__NilErrorObject) return undefined;
return value;
}
28 changes: 24 additions & 4 deletions src/transformation/visitors/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as lua from "../../LuaAST";
import { FunctionVisitor, TransformationContext } from "../context";
import { unsupportedForTarget, unsupportedForTargetButOverrideAvailable } from "../utils/diagnostics";
import { createUnpackCall } from "../utils/lua-ast";
import { transformLuaLibFunction } from "../utils/lualib";
import { importLuaLibFeature, transformLuaLibFunction } from "../utils/lualib";
import { findScope, LoopContinued, Scope, ScopeType } from "../utils/scope";
import { isInAsyncFunction, isInGeneratorFunction } from "../utils/typescript";
import { wrapInAsyncAwaiter } from "./async-await";
Expand Down Expand Up @@ -171,8 +171,23 @@ export const transformTryStatement: FunctionVisitor<ts.TryStatement> = (statemen
const returnedIdentifier = lua.createIdentifier("____hasReturned");
let returnCondition: lua.Expression | undefined;

const pCall = lua.createIdentifier("pcall");
const tryCall = lua.createCallExpression(pCall, [lua.createFunctionExpression(tryBlock)]);
// On Lua 5.5 (and Universal, which must work on 5.5), a `nil` error object
// is replaced by a string when it propagates out of a protected call. To
// preserve `throw undefined` semantics, route through xpcall with a message
// handler that wraps nil into a sentinel, and unwrap before the user catch.
// See: https://www.lua.org/manual/5.5/manual.html#8.1
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the result if we did not do this workaround? Do we get failing tests? Are we getting unexpected type failures at runtime?

Copy link
Copy Markdown
Contributor Author

@RealColdFry RealColdFry May 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Running the below snippet

ok, err = pcall(function() error(nil) end)
print(ok, err, type(err))

In Lua 5.0-5.4, LuaJIT, Luau (Lune) gives

false   nil     nil

while in 5.5:

false	<no error object>	string

Without the wrapper, error(nil) in Lua 5.5 surfaces as the string "<no error object>" rather than nil. This breaks the error.spec.ts "throw and catch undefined" test (https://github.com/TypeScriptToLua/TypeScriptToLua/actions/runs/25261709047/job/74069980641?pr=1723#step:6:250).

  ● throw and catch undefined                                                                                                                
                                                                                                                                             
    expect(received).toEqual(expected) // deep equality                                                                                      
                                                                                                                                             
    Expected: undefined                                                                                                                      
    Received: "<no error object>"                                                                                                            
                                                                                                                                             
      405 |         const luaResult = this.getLuaExecutionResult();                                                                          
      406 |         const jsResult = this.getJsExecutionResult();                                                                            
    > 407 |         expect(luaResult).toEqual(jsResult);                                                                                     
          |                           ^                                                                                                      
      408 |                                                                                                                                  
      409 |         return this;                                                                                                             
      410 |     }                                                                                                                            
                                                                                                                                             
      at FunctionTestBuilder.expectToMatchJsResult (test/util.ts:407:27)                                                                     
      at test/unit/error.spec.ts:320:7 

Otherwise, in

function doThing() {
  throw undefined;
}

function main() {
  try {
    doThing();
    return "didThing";
  } catch (
    e // e: unknown
  ) {
    if (e === undefined) {
      // handle the "thrown undefined" case
      return "undefined";
    }
    if (e instanceof Error) {
      return "Error[" + e.message + "]";
    }
    if (typeof e === "string") {
      // Lua 5.5 gives a string
      return "string[" + e + "]";
    }
    return "something else";
  }
}

console.log(main());

if doThing() threw undefined, on 5.5 e is actually the string <no error object> at runtime.

const wrapErrorObjects =
context.options.luaTarget === LuaTarget.Lua55 || context.options.luaTarget === LuaTarget.Universal;
if (wrapErrorObjects) {
importLuaLibFeature(context, LuaLibFeature.ErrorObject);
}

const tryCall = wrapErrorObjects
? lua.createCallExpression(lua.createIdentifier("xpcall"), [
lua.createFunctionExpression(tryBlock),
lua.createIdentifier("__TS__WrapErrorObject"),
])
: lua.createCallExpression(lua.createIdentifier("pcall"), [lua.createFunctionExpression(tryBlock)]);

if (statement.catchClause && statement.catchClause.block.statements.length > 0) {
// try with catch
Expand All @@ -192,9 +207,14 @@ export const transformTryStatement: FunctionVisitor<ts.TryStatement> = (statemen
}
result.push(lua.createVariableDeclarationStatement(tryReturnIdentifiers, tryCall));

const catchArg = wrapErrorObjects
? lua.createCallExpression(lua.createIdentifier("__TS__UnwrapErrorObject"), [
lua.cloneIdentifier(returnedIdentifier),
])
: lua.cloneIdentifier(returnedIdentifier);
const catchCall = lua.createCallExpression(
catchIdentifier,
statement.catchClause.variableDeclaration ? [lua.cloneIdentifier(returnedIdentifier)] : []
statement.catchClause.variableDeclaration ? [catchArg] : []
);
const catchCallStatement = hasReturn
? lua.createAssignmentStatement(
Expand Down
6 changes: 5 additions & 1 deletion test/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,15 @@ function getLuaBindingsForVersion(target: tstl.LuaTarget): { lauxlib: LauxLib; l
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.53");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.Lua54) {
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.54");
return { lauxlib, lua, lualib };
}
if (target === tstl.LuaTarget.LuaJIT) {
throw Error("Can't use executeLua() or expectToMatchJsResult() with LuaJIT as target!");
}

const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.54");
const { lauxlib, lua, lualib } = require("lua-wasm-bindings/dist/lua.55");
return { lauxlib, lua, lualib };
}

Expand Down
Loading