This document explains how try-as turns AssemblyScript try/catch/finally, throw, abort, unreachable, and selected stdlib throws into catchable control flow without relying on native Wasm exception support.
- Make transformed
throw,abort, andunreachablecatchable from AssemblyScript source. - Preserve useful payload information, including primitive values, managed objects, and
Errormetadata. - Propagate failures across normal function calls, methods, imports, and re-export chains.
- Keep the runtime small and push most complexity into a compile-time AST transform.
- Allow opt-in catch filtering with
// @try-as: ....
- Catching low-level Wasm traps such as out-of-bounds memory faults.
- Rewriting every internal AssemblyScript runtime path.
- Providing a general-purpose effect system or a replacement for native exceptions once Wasm EH is universally available.
try-as is published as one npm package with two roles:
| Path | Role |
|---|---|
package.json |
Package metadata. main points to the Node-side transform entry. |
index.ts |
Root AssemblyScript export surface. Re-exports from assembly/. |
assembly/ |
Runtime types and state holders used by transformed user code. |
transform/src/ |
Transform source written in TypeScript against the AssemblyScript compiler AST. |
transform/lib/ |
Built JavaScript version of the transform that asc loads. |
assembly/__tests__/ |
End-to-end specs compiled with the transform and executed in Wasm. |
This dual packaging is important:
asc --transform try-asloads the Node transform viapackage.json.main.import { Exception } from "try-as"resolves to the AssemblyScript runtime files viaexports["."]andtypes.
AssemblyScript sources
-> asc parses sources
-> try-as afterParse hook runs
-> source graph is analyzed from user entry files
-> exception-capable refs are marked
-> AST is rewritten in-place
-> helper imports are injected
-> asc continues to type-check and emit Wasm
At runtime, transformed code follows a simple state-machine model:
exception site
-> write shared exception state
-> increment ExceptionState.Failures
-> break/return out of the current rewritten scope
-> nearest rewritten catch checks ExceptionState.shouldCatch(mask)
-> catch reconstructs Exception from shared state
-> finally executes
-> if uncaught, outer rewritten callers see Failures > 0 and keep unwinding
The transform entry lives in transform/src/index.ts and subclasses AssemblyScript's Transform.
The transform runs after parsing and performs these steps in order:
- Read environment-controlled options:
TRY_AS_REWRITE_STDLIBTRY_AS_IMPORT_SCOPETRY_AS_DIAGNOSTICS- legacy debug helpers such as
DEBUGandWRITE
- Detect whether the transform is running from a local checkout or from
node_modules. - Parse helper sources like
assembly/types/exception.tsandassembly/types/unreachable.tsinto the compilation if they are not already present. - Filter out internal sources that are intentionally unsupported:
~lib/rt~lib/shared~lib/wasi_~lib/performance
- Record the effective working directory in
Globals.baseCWD. - Run
SourceLinker.link(...)to analyze and rewrite the program. - Optionally run
StdlibThrowRewriter.rewrite(...). - Run
ThrowReplacer.replace(...). - Optionally dump transformed source snapshots when
WRITE=...is set.
The ordering matters:
SourceLinker.link(...)is the main analysis and generation pass.StdlibThrowRewriterconverts stdlibthrowstatements into the same helper-state model.ThrowReplacerhandles post-processing that depends on the generated shape, especiallyrethrowand method call targeting.
The AssemblyScript runtime side lives under assembly/types/.
assembly/types/exception.ts defines:
NoneAbortThrowUnreachable
These values are also used to build bitmasks for selective catch handling.
ExceptionState is the shared global state used by transformed code:
Failures: nesting-aware count of active transformed failures.Type: the currentExceptionType.DefaultValue: an 8-byte memory slot used byException.as<T>()as a fallback zero/default load.shouldCatch(mask): checks whether the active failure kind is included in the catch mask.
This is the central propagation channel. There is no hidden stack object per try; transformed code cooperates through this shared state.
assembly/types/abort.ts stores:
- abort message
- file name
- line number
- column number
AbortState.abort(...) increments ExceptionState.Failures, sets ExceptionState.Type = Abort, and records metadata.
assembly/types/unreachable.ts is intentionally minimal:
unreachable()incrementsFailures- it sets
Type = Unreachable
assembly/types/error.ts is the most complex state holder because throw can carry many payload kinds.
It stores:
- error message, name, and stack when the payload is an
Error - file/line/column metadata
discriminatordescribing the thrown payload typestorage, an 8-byte slot holding the raw thrown valuemanaged, a retained managed reference when needed- flags telling the runtime whether the payload behaved like
Erroror had a meaningful string form
ErrorState.error<T>(value, file, line, column):
- increments
Failures - sets
Type = Throw - records source metadata
- computes a payload discriminator
- stores the raw value
- captures managed references for GC visibility
- fills message/name/stack when the payload is an
Error - falls back to
toString()when available
Exception is the object a transformed catch receives.
Important behavior:
- The constructor snapshots the active global state into instance fields.
is<T>()compares the stored discriminator.as<T>()returns the stored value if the discriminator matches, otherwise it returns a default value viaExceptionState.DefaultValue.clone()deep-copies the 8-byte payload storage buffer.rethrow()always uses its runtime method body; forThrow, that degrades toabort(message, ...).__try_rethrow()writes the exception back into shared state so transformed rethrows preserve the active failure.__visit(...)keeps managed payloads visible to the GC when needed.
transform/src/globals/globals.ts defines a singleton Globals used during one compilation:
sources: map of parsed source path toSourceRefcallStack: active call chain during analysisrefStack: active nested refs being exploredfoundException: flag used while propagating exception capabilitylastTry: currentTryReflastFnandparentFn: current function/method contextmethods: all discovered methods, used later byThrowReplacer
This singleton is what allows independent visitors and ref objects to coordinate.
The main analysis does not work directly on raw AST nodes alone. It first builds a graph of reference objects.
| Ref type | Purpose |
|---|---|
SourceRef |
Owns per-file state, local symbols, dependency links, and generated refs. |
SourceLocalRef |
Stores local namespaces, classes, functions, imports, and exports discovered during gather. |
FunctionRef |
Tracks a function, its callers, nested try blocks, and direct/indirect exception sites. |
MethodRef |
Same as FunctionRef, but scoped to a ClassRef. |
TryRef |
Represents one try/catch/finally region and generates the lowered control flow. |
CallRef |
Represents a call to an exception-capable function or method. |
ExceptionRef |
Represents a direct throw, abort, or unreachable site. |
NamespaceRef |
Tracks nested namespace structure. |
ClassRef |
Tracks classes and their methods. |
BaseRef |
Shared base with hasException and visited flags. |
The key idea is that the transform separates discovery from generation:
- discovery builds this graph
- linking marks the nodes that participate in exception flow
- generation mutates only the marked nodes
transform/src/passes/source.ts is the heart of the transform.
SourceLinker.gather() walks a source once to collect structure:
- imports and exports
- namespaces
- classes
- methods
- functions
- entry functions in
SourceKind.UserEntrysources
During this phase it also:
- records file dependencies
- recursively gathers imported sources
- normalizes
ifbranches into blocks so later code insertion is simpler
No rewriting happens yet. The goal is to index the program.
SourceLinker.link(true) starts from each user entry source and discovers exception flow.
It does this by visiting:
CallExpressionThrowStatementTryStatement
Key behaviors:
- Direct
abort()andunreachable()calls createExceptionRefs immediately. throwstatements createExceptionRefs.- Function and method calls resolve through
SourceRef.findFn(...), which can walk local declarations, imports, aliases, and re-export chains. - Nested
tryblocks createTryRefs attached either to the surrounding function/method or to the source itself.
When an exception-capable path is found, smashStack() marks every currently active ref on Globals.refStack and every active caller on Globals.callStack as hasException = true.
That propagation step is why a deeply nested abort() causes:
- the direct site to be rewritten
- its containing function or method to be rewritten
- each caller on the active analysis path to gain the same rewritten propagation behavior
Once marked refs are known, generate() is called starting from each entry SourceRef.
Generation is distributed across ref types:
SourceRef.generate()drives file-level outputFunctionRef.generate()rewrites functionsMethodRef.generate()rewrites methodsCallRef.generate()rewrites callsExceptionRef.generate()rewrites direct exception sitesTryRef.generate()lowerstry/catch/finallyNamespaceRef.generate()andClassRef.generate()recurse into contained items
After generation, SourceLinker:
- repeatedly adds
__try_*re-exports until stable - injects helper imports into user sources, or into all sources if configured
ExceptionRef.generate() converts direct exception-producing syntax into helper-state writes.
Conceptually:
abort("boom");becomes:
__AbortState.abort("boom");
return;or:
__AbortState.abort("boom");
break;depending on whether the transform is currently inside a rewritten try loop or a function body.
Conceptually:
unreachable();becomes:
__UnreachableState.unreachable();
return;or break, for the same reason.
Conceptually:
throw value;becomes:
__ErrorState.error(value, "file.ts", "12", "8");
return;or break.
The inserted breaker comes from getBreaker(...) in transform/src/utils.ts.
getBreaker(...) synthesizes the right early-exit node for the surrounding context:
breakwhen escaping thedo { ... } while(false)generated for atryreturnforvoidreturn falseforboolreturn 0for integer and float typesreturn changetype<T>(0)for managed/reference types- plain
returnas the final fallback
This is how the transform unwinds control flow without native exception support.
FunctionRef.generate() rewrites every marked function.
It performs these steps:
- Clone the original body before mutation.
- Prepend an "unroll" check:
if (__ExceptionState.Failures > 0) {
return defaultValue;
}- Rewrite direct exception sites in the body.
- Rewrite outgoing calls to exception-capable callees.
- Lower nested
tryblocks. - If the function has no local
try, rename the transformed implementation to__try_<name>and append a sibling function with the original name and original body.
That last step is the key internal/external split:
- transformed callers are redirected to
__try_* - the original symbol name remains available for untouched callers and exports
Functions that contain a local try are not renamed, because the catch logic must stay on the function's public entry path.
MethodRef.generate() follows the same pattern as FunctionRef.generate():
- prepend unroll check
- rewrite direct exception sites
- rewrite outgoing calls
- lower nested
try - rename to
__try_*only when the method itself has no localtry
There is no import generation step for methods, but method calls still need careful target resolution. That is handled later by ThrowReplacer.
CallRef.generate() updates calls to exception-capable functions or methods.
Conceptually:
work();becomes:
__try_work();
if (__ExceptionState.Failures > 0) {
return defaultValue;
}The post-call check is only inserted when the call sits in statement position. If the call occurs in an expression, the transform rewrites the callee name but cannot always inject a trailing statement in the same way.
TryRef.generate() turns one structured try into explicit control flow.
Conceptually, this:
try {
stepA();
stepB();
} catch (e) {
handle(e as Exception);
} finally {
cleanup();
}becomes code shaped like:
do {
__try_stepA();
if (__ExceptionState.Failures > 0) break;
__try_stepB();
if (__ExceptionState.Failures > 0) break;
} while (false);
if (__ExceptionState.shouldCatch(<i32>14)) {
let e = new __Exception(__ExceptionState.Type);
__ExceptionState.Failures--;
handle(e as Exception);
}
{
cleanup();
}Important details:
- The
do { ... } while(false)wrapper gives the transform a place tobreak. - The catch variable is initialized with
new __Exception(__ExceptionState.Type). Failuresis decremented only when the generated catch actually handles the failure.finallyis emitted as a trailing block and therefore runs whether or not the catch matches.
Selective catch is implemented in TryRef.resolveCatchMask().
The transform looks at the line immediately above the try for an exact directive:
// @try-as: throw,abort
try {
...
} catch (e) {
...
}Supported catch kinds:
throwabortunreachable
The directive is converted into a bitmask and passed to ExceptionState.shouldCatch(mask).
If no directive is present, the default mask catches all three transformed failure kinds.
Cross-file propagation is handled by SourceRef.
SourceRef.findImportedFn(...), findImportedMethod(...), and findImportedNs(...):
- check local import bindings
- remap local aliases back to foreign names
- locate the referenced source
- continue resolving through re-export chains
This is what allows a failure in one file to mark callers in another file.
After generation, SourceLinker.addTryReexports(...) repeatedly scans export statements and adds missing __try_* export members when the target source exports them.
That fixed-point loop is why re-export chains continue to work after internal symbol renaming.
The tests under assembly/__tests__/reexports*.ts cover these cases.
transform/src/passes/stdlib.ts rewrites eligible stdlib throw statements inside ~lib/* sources.
It intentionally skips:
~lib/rt~lib/performance~lib/wasi_~lib/shared/~lib/try-as/
For eligible stdlib functions and methods, it converts:
throw new Error("x");into the same __ErrorState.error(...) + breaker pattern used for user code.
Constructor methods are skipped.
This pass is controlled by TRY_AS_REWRITE_STDLIB.
transform/src/passes/replacer.ts handles rewrites that are easier once the main graph has been generated.
It does two important jobs.
Direct method calls named rethrow are left alone, so Exception.rethrow() keeps its runtime behavior.
Identifier throws are split into two cases.
If the thrown identifier is statically typed as Exception or an Exception subclass, the transform lowers:
throw err;directly to:
err.rethrow();For all other identifier throws, the transform keeps the guarded fallback path.
That fallback rewrites identifier throws of the form:
throw e;into a guarded form:
if (isDefined(e.__try_rethrow)) {
e.__try_rethrow();
} else if (isDefined(e.rethrow)) {
e.rethrow();
} else {
throw e;
}This keeps explicit Exception rethrows on the runtime path while still supporting custom identifier rethrow helpers for non-Exception values.
Method rewriting is trickier than free functions because different classes can share the same method name.
ThrowReplacer:
- indexes marked methods by
name/arity - tracks a scope stack of variable type hints
- tracks the current class
- distinguishes static vs instance calls
- infers receiver type from
this,new, assertions, parenthesized expressions, and locally typed identifiers
If it can resolve a unique marked method target, it rewrites:
obj.fail();to:
obj.__try_fail();If the target is ambiguous, it leaves the call alone rather than guessing.
The tests in assembly/__tests__/method-rewrite.spec.ts validate this behavior.
After generation, SourceLinker.addImports(...) injects aliased imports for:
AbortStateas__AbortStateUnreachableStateas__UnreachableStateErrorStateas__ErrorStateExceptionas__ExceptionExceptionStateas__ExceptionState
The relative path is computed from the current source to assembly/types/.
TRY_AS_IMPORT_SCOPE controls where these imports are injected:
allinjects into every eligible sourceuserlimits injection toSourceKind.UserandSourceKind.UserEntry
Source:
function inner(): void {
abort("boom");
}
export function run(): void {
try {
inner();
} catch (e) {
trace((e as Exception).toString());
}
}Conceptual transformed shape:
function __try_inner(): void {
if (__ExceptionState.Failures > 0) return;
__AbortState.abort("boom");
return;
}
function inner(): void {
abort("boom");
}
export function run(): void {
if (__ExceptionState.Failures > 0) return;
do {
__try_inner();
if (__ExceptionState.Failures > 0) break;
} while (false);
if (__ExceptionState.shouldCatch(<i32>14)) {
let e = new __Exception(__ExceptionState.Type);
__ExceptionState.Failures--;
trace((e as Exception).toString());
}
}The emitted AST is not exactly this text, but this example captures the architecture:
- direct failures become state writes
- callers propagate via generated early returns
tryboundaries become explicit loops and catch checks
The codebase currently uses several environment variables:
TRY_AS_REWRITE_STDLIB=0disables stdlib rewriting.TRY_AS_IMPORT_SCOPE=userrestricts helper import injection to user sources.TRY_AS_DIAGNOSTICS=1prints the active transform mode.DEBUG=1or higher enables verbose internal logging in several passes.WRITE=pathA,pathBwrites selected transformed sources as*.tmp.tssnapshots.
These are intentionally low-level and aimed at contributors debugging the transform.
run-tests.sh is the primary integration harness.
For each assembly/__tests__/**/*.spec.ts file it:
- compiles the spec with
npx asc ... --transform ./transform - writes optional debug snapshots
- runs the resulting Wasm with
wasmtime
The current test suite covers:
- direct
aborthandling - nested propagation
finally- thrown primitives and managed objects
Exception.is<T>()andException.as<T>()- selective catch masks
- import/re-export chains
- method resolution and receiver evaluation
- selected stdlib throw sites
The architecture intentionally stops at a few boundaries:
- Internal runtime sources such as
~lib/rtare excluded from rewriting. - Native traps are not catchable through this model.
- Selective catch only works with the exact
// @try-as: ...syntax on the line immediately above thetry. - Method target resolution is heuristic and syntax-driven; ambiguous cases are left untouched.
- The runtime model is based on shared mutable state, so the transform assumes the normal single-threaded AssemblyScript execution model.
The simplest way to think about try-as is:
- at compile time, it builds a graph of code that can participate in transformed failures
- it rewrites those paths into explicit state updates and early exits
- it turns each
tryinto a structured catch checkpoint - at runtime,
Exceptionis just a snapshot view over shared failure state
Everything else in the codebase exists to make that work across files, methods, re-exports, and a useful subset of stdlib behavior.