TJS Performance Guide
Numbers live in benchmarks.md, which is generated. This page carries
the guidance that outlives any particular measurement.
Why this page has no table
It used to have one, hand-maintained, and it drifted an order of magnitude:
| Claim | This page (2026-01-19) | Measured (2026-08-18) |
|---|---|---|
| Safe function vs baseline | 17–28× | 1.6–2.0× |
| Safe helper in a loop, per call | 12× | 1.9× (vs unsafe) |
The old figures were real when written — validation went through a wrap() call that the
emitter no longer produces. Nothing was wrong except that a second, hand-copied set of
numbers existed at all: it could not be regenerated, so it could not be noticed going stale,
and for seven months this guide told readers that safe TJS cost them 17–28× when it cost
under 2×. That is a discouraging lie about the language's central feature.
benchmarks.md is written by bun run bench and carries its own date, runtime and platform.
It is the only place timings belong. When you want a number, run the benchmark.
Safe by default, unsafe where it is measured to matter
// Safe (default) — validates arguments and return at run time
function add(a: 0, b: 0): 0 { return a + b }
// Unsafe — no validation, plain-JS cost
function fastAdd(! a: 0, b: 0): 0 { return a + b }
Validation cost is per CALL and proportional to the shape being checked, so it is invisible whenever the function does real work and visible when the function is trivial and hot.
Validation cost lives at the callee
This is the one thing worth internalising, and it is not obvious: marking the OUTER function unsafe does nothing for the helper it calls in a loop.
function process(! arr: [0]): 0 {
let sum = 0
for (const x of arr) {
sum += double(x) // if `double` is safe, this loop pays for it, every iteration
}
return sum
}
The fix is to mark the callee:
function double(! x: 0): 0 { return x * 2 }
A 100-element loop run 10,000 times — one million helper calls (2026-08-19, Bun 1.3.14, darwin arm64):
| Helper | Best of 7 | vs plain JS |
|---|---|---|
| plain JS | 0.62ms | 1.0× |
TJS, ! unsafe |
1.48ms | 2.4× |
| TJS, safe (validated) | 2.75ms | 4.5× |
So the safe helper is the more expensive choice by ~1.9× against the unsafe one — and still under 3ms for a million calls, which is why "safe by default, unsafe where you have measured a problem" is the right order to work in.
These four numbers are hand-measured and no committed harness reproduces them. That is
the exact sin this page opens by describing, so they carry their date and this warning
rather than an invitation to "re-derive with bun run bench", which measures something
else. Treat them as an illustration of the SHAPE — cost lives at the callee, and it is
small — not as figures to quote. bun run bench is the authority for anything you plan to
repeat. Adding the loop-with-helper case to bin/benchmarks.ts would retire this table;
that is tracked in TODO.md.
There is no unsafe {} block
This section used to describe one, with semantics ("wraps code in try-catch") and a measured
overhead for a form that does not parse. unsafe is an expression PREFIX
(unsafe new Date(0)) or a function marker (function f(! x: 0)) — never a block.
Recommendations
- Safe by default. Use it at API boundaries and for anything touching untrusted input.
- Unsafe for hot callees. Mark the inner function, not the outer one.
- Validate once at the edge. Check at entry, then use unsafe internally.
- Measure before optimising. Run
bun run bench; do not copy a number out of a guide.
Future: type-flow optimisation
Planned compile-time work will skip checks it can prove redundant — an output type that
matches the next input, a known array element type — so hot paths stop needing the manual
!.
Running benchmarks
bun run bench # regenerates benchmarks.md
bun test src/lang/perf.test.ts # the transpiler's own performance tests