How bun compile uses your types

What a declared type gets you in a compiled program, what happens when a value doesn't match it, and which patterns keep your code on the fast path

Ahead-of-time compilation is experimental.

bun run removes your TypeScript types before it runs your program. bun compile reads them first and generates machine code that relies on them.

What types get you#

account.ts
class Account {
  constructor(
    public id: number,
    public balance: number,
  ) {}
}

function total(accounts: Account[]): number {
  let sum = 0;
  for (const account of accounts) sum += account.balance;
  return sum;
}

const accounts: Account[] = [];
for (let id = 0; id < 1000; id++) {
  const account = new Account(id, id % 100);
  accounts.push(account);
}

const sum = total(accounts);
console.log(sum);

bun check --aot checks a program the way bun compile does and builds nothing. With --explain, it lists the code that compiled to a slow path. This program has none.

terminal
bun check --aot ./account.ts --explain
note: Every property access is typed. Nothing to fix.

Typed code has three things that untyped code doesn't.

  • Fields at fixed offsets. Every Account has the same two fields in the same places. account.balance is one load from a known offset, with no lookup and no cache to check.
  • Unboxed numbers. balance is stored as a raw 64-bit number. Untyped code stores a boxed value and has to check its tag and unbox it before it can add.
  • Direct calls. The compiler knows which function total(accounts) calls, so it can call that function directly or inline it.

Here is the same program as JavaScript.

account.js
class Account {
  constructor(id, balance) {
    this.id = id;
    this.balance = balance;
  }
}

function total(accounts) {
  let sum = 0;
  for (const account of accounts) sum += account.balance;
  return sum;
}

const accounts = [];
for (let id = 0; id < 1000; id++) {
  const account = new Account(id, id % 100);
  accounts.push(account);
}

const sum = total(accounts);
console.log(sum);
terminal
bun check --aot ./account.js --explain
note: Every property access is typed. Nothing to fix.

Both programs compile, both print 49500, and both are typed. The annotations aren't what puts balance at a fixed offset. The class is. A class has a fixed layout, with its fields at fixed offsets like a struct in Go or Rust, and that's true in a JavaScript file too. The compiler then tracks every Account from new Account, through the array, to account.balance, so it knows what the loop reads from. What is counted says how far it can track an object, and what stops it.

Type annotations matter where the compiler can't track a value on its own. The result of JSON.parse could be anything, and so could the argument of a function that Bun calls, such as a route handler.

  • With a declared type, the code after it stays on the fast path. A guard checks the value once, where it reaches the type. After n: number, the compiled code works with a raw number.
  • Without one, nothing is known about the value. Every +, every comparison and every property access that the value reaches has to find out what it has.
  • A parse uses the declared type too. const config: Config = JSON.parse(text) creates objects with the fixed layout of Config.

When a value doesn't match its type#

TypeScript lets you call a value a number when it isn't one: through any, as, !, JSON.parse and declaration files.

visits.ts
function double(n: number): number {
  return n * 2;
}

const body: any = JSON.parse('{"visits":"3"}');
console.log(double(body.visits));
terminal
bun compile ./visits.ts && ./visits
6

bun visits.ts prints 6 too.

Typed code trusts its declared types. Where the build sees every caller and each one passes the declared types, nothing is checked at run time. Otherwise a guard at the function's entry checks the arguments, and Bun also compiles a slow version of that function, which works like plain JavaScript on the value that didn't match. If an argument doesn't match its declared type, the call takes the slow version. The result is the same as under bun run. It's just slower.

Here double takes a raw number, so it can't take the string "3". The guard sends the call to the slow version, which multiplies the way plain JavaScript does.

  • Nothing throws. A type mismatch is never an error at run time.
  • Nothing is converted or copied. The string stays a string, and an object stays the same object.
  • The fallback is per value. The next call of double with a number takes the fast version again.

A guard in the middle of a function works the same way. The function continues on its slow path from that point. It returns to the fast path at the next loop iteration, once every live value matches its type again.

A type error stops the build, so all of this is about code that TypeScript accepts.

Where a guard sits#

The compiler removes a guard where it can prove the value. A call from typed code with proven arguments skips the entry guard and calls the fast version directly. The guards that are left sit where an untyped value can get in.

WhereThe guard checksIf it fails
A parameter: function double(n: number)Each argument, at the function's entryThe call falls back to the function's slow version
this in a methodThat this is an instance of the class. counter.reset.call(other) fails itThe call falls back to the method's slow version
The result of a call through a function type or an unknown callee: handlers[name](request)The result, after the call returnsThe function falls back to its slow path
An element of a collection: items[0], map.get(key), for (const item of items), await promiseThe element, after it's readThe function falls back to its slow path
A field or variable that untyped code can writeThe value, after it's readThe function falls back to its slow path
An as cast from a looser type: body as User, query.page as numberThe value of the castThe code after the cast falls back to its slow path

A guard checks the kind of a primitive or the shape of an object. It doesn't look inside an array or an object.

Declared typePasses the guard
number, string, boolean, bigintA value of that kind
A class, an interface or an object typeAn object that was created as that type: with new, as an object literal of that type, or by a parse that is declared as that type. This guard is a shape check
An array type, such as number[]An array. A Proxy around an array doesn't
Any typeundefined and null

bun check --aot --explain=guards lists every guard that can fail. --aot-log-type-mismatches logs the ones that do.

undefined and null#

port.ts
const ports = new Map<string, number>();
const port: number = ports.get("http")!;
console.log(port);
terminal
bun compile ./port.ts && ./port
undefined

Every declared type also takes undefined and null, so they never fail a guard.

  • double(undefined) returns NaN and double(null) returns 0, compiled or not.
  • If you store undefined in a number field of a class, the field reads undefined.
  • A property that an object doesn't have reads as undefined.
  • With strictNullChecks, TypeScript still reports an undefined or a null that it can see, and that stops the build like any other type error.

Unsound casts#

as, any and ! tell TypeScript to stop checking. In a compiled program they never convert a value and never throw. A cast from a looser type costs a guard.

page.ts
const query: any = { page: "2" };
const page = query.page as number;
console.log(typeof page, page + 1);
terminal
bun compile ./page.ts && ./page
string 21

bun page.ts prints the same. The fast path after the cast works with a raw number. The guard sees a string, so the code after the cast takes the slow path and concatenates.

TypeScript accepts such a cast, so it's easy to miss. --explain=guards lists each one as cast.

If you want a conversion, write it out.

const page = query.page as number; 
const page = Number(query.page); 

Unchecked data keeps its shape#

JSON.parse, response.json() and structuredClone return objects that the compiler never saw being created. A cast or an annotation doesn't change them.

user.ts
interface User {
  id: number;
  name: string;
}

const user = JSON.parse('{"id":"7"}') as User;
console.log(user.name);
console.log(user.id);
terminal
bun compile ./user.ts && ./user
undefined
7

Nothing says that the text matches User, so every property access that the object can reach needs a shape check first. This object has a different shape from a User that your own code creates. It fails the check, and its properties are looked up by name. bun compile --explain points to the line where the object comes from.

The same goes for a value whose type comes from a JavaScript package's declaration file (.d.ts). Nothing has checked that file against the package's code.

Declare the type where you parse#

This text matches User. The object still fails the guard of greet, because a guard compares shapes, and nothing told JSON.parse about User.

parsed.ts
interface User {
  id: number;
  name: string;
}

function greet(user: User): string {
  return user.name + " #" + user.id;
}

const read = (text: string): any => JSON.parse(text);
console.log(greet(read('{"id":7,"name":"Ada"}')));

--aot-log-type-mismatches logs every guard that fails.

terminal
bun --aot-log-type-mismatches parsed.ts
[aot] type-mismatch 2890618d9d59 argument parsed.ts:6:16 (greet): expected User, got untyped object; continued; count 1; from parsed.ts:11
Ada #7

Now declare what the text contains.

parsed.ts
interface User {
  id: number;
  name: string;
}

function greet(user: User): string {
  return user.name + " #" + user.id;
}

const read = (text: string): User => JSON.parse(text); 
console.log(greet(read('{"id":7,"name":"Ada"}')));
terminal
bun --aot-log-type-mismatches parsed.ts
Ada #7

Nothing is logged. Bun compiles a parser for the type that a call of JSON.parse is declared as.

You writeThe declared type is
const user: User = JSON.parse(text)The annotation
JSON.parse(text) as UserThe cast
greet(JSON.parse(text))The parameter of your function
function read(text: string): User { return JSON.parse(text) }The return type
const user: User = await response.json()The annotation. request.json() too
  • If the text matches the type, the parser creates the objects with the fixed layout of User. They pass every guard for User, so typed code stays on its fast path.
  • If it doesn't, you get what the ordinary JSON.parse returns, as in user.ts above.
  • Nothing is checked, and nothing throws except the SyntaxError for a text that isn't JSON.
  • The shape checks stay. The compiler can't know what's in the text, and a text can have keys that User doesn't declare. So every access to a User checks the shape first, anywhere in the program. --explain lists the call under unknown-object and property-added.

So declare the type for objects that mostly come from parsed data. To remove the checks, or to reject data that doesn't match, take the value as unknown, narrow it, and build a new object from it. The unknown-object entry of --explain has an example.

What costs nothing#

You writeAt run time
value!Nothing. undefined and null pass the next guard too
as any, as unknown, as const, satisfiesNothing
A cast to a wider type, such as circle as ShapeNothing
A type guard of your own, value is UserThe function runs. The compiler doesn't rely on its answer
A type parameter, such as T in first<T>(list: T[]): TNothing. The value gets a guard where it reaches a concrete declared type
A type that a package's .d.ts file declaresNothing, and the compiler assumes nothing about the value until it reaches one of your own types

Patterns that fall off the fast path#

When the compiler can't prove which objects reach a property access, it doesn't stop. It compiles that access like plain JavaScript, which behaves the same and runs slower. bun compile --explain finds these patterns in your program and counts the accesses each one slows down. An any or a .js file isn't one of them. What matters is where the objects come from, and what happens to them.

PatternWhat it costsInstead
JSON.parse(text) as ConfigEvery access that the object can reach needs a shape check first. In cold code, it looks the property up by nameFine where the data is read a few times. For hot code, build a new object from it
Unchecked data in an any that is returned or passed as a declared typeThe sameReturn a new object literal
An object passed to code that isn't in the executable, such as a module loaded at run timeThe same, for every access to its typePass only the fields the other code needs, not the object
A delete of a required property, Object.defineProperty, Object.setPrototypeOf, a store with a computed keyThe same, for every access to the object's typeEach of them has an entry of its own, with a fix
Object.freeze(value), Object.seal(value), Object.preventExtensions(value)Every store to a field of that type checks for a frozen object firstFreeze only what has to be frozen. readonly and Readonly<T> catch a store at build time, and cost nothing
class HttpError extends Error, or any other built-in base classThe built-in constructor allocates the instances, so their fields can't be at fixed offsets. They're looked up by nameThis is fine for error classes. For a class with many instances, hold the built-in object in a field instead of extending it
eval, new Function, node:vmBun interprets that codeCode from strings

External data only slows down the code it reaches. If a parsed object ends up in one array, the reads from that array need a shape check, and reads of the same type elsewhere stay proven. The exception is a parse that's declared as the type.

Changing an object's shape slows down every access to that type, anywhere in the program, because any object of the type could be the one that changed. Read an entry works through an example.

32-bit integers#

int32 is a whole number from -2147483648 to 2147483647. To TypeScript, and under bun run, it's a number. In a compiled executable, a variable, field, parameter or return value of that type takes four bytes instead of eight, and arithmetic on it skips the checks for fractions.

cursor.ts
import type { int32 } from "bun";

class Cursor {
  line: int32 = 0;
  column: int32 = 0;
}

function advance(cursor: Cursor, by: int32): int32 {
  cursor.column = cursor.column + by;
  return cursor.column;
}

console.log(advance(new Cursor(), 4));

The build has to prove that every value you store in an int32 is one. Where it can't, it stops, and the error says what to write. cursor.ts doesn't pass yet.

terminal
bun check --aot ./cursor.ts
cursor.ts(9,19): error TS100031: Property 'column' of 'Cursor' is declared 'int32', but 'cursor.column + by' might not be an int32: it can be anything from -4294967296 to 4294967294. Write '(cursor.column + by) | 0'.
cursor.column = cursor.column + by; 
cursor.column = (cursor.column + by) | 0; 

Because the build proves every store that it sees, typed code has nothing to check when it stores an int32. | 0 is ordinary JavaScript: it gives the same 32-bit value compiled, under bun run and in every other engine.

The build acceptsIt doesn't accept, and what to write
A whole number in range: 7, -1, 2 ** 31 - 11.5, 2 ** 31, -0, NaN
Another int32A number or an any: n | 0
A numeric enum or a union of literals in which every member is a whole number in range: 0 | 1 | 2One that holds 1.5 or 2 ** 31, an enum with a computed member, a declare enum: n | 0
a | b, a & b, a ^ b, ~a, a << n, a >> na >>> 0, which goes up to 4294967295: a | 0
Math.imul(a, b), Math.clz32(a)a * b: Math.imul(a, b)
Math.min(a, b), Math.max(a, b), Math.sign(a)Math.abs(a), which goes up to 2147483648: Math.abs(a) | 0
text.length, text.indexOf(..), map.size, set.sizearray.length, which goes up to 4294967295: array.length | 0
Arithmetic that stays in range: (a & 255) * 3 + 1a + b and a - b of two int32 values, which can overflow: (a + b) | 0
i + 1 and i++ where a test limits i: i < end, i < text.lengthi++ with no limit in sight: i = (i + 1) | 0
a % b where a isn't negative and b is positivea / b: (a / b) | 0
Math.floor(a / 2). Math.ceil, Math.round and Math.trunc too, if the quotient can't be negative: Math.trunc((a & 1023) / 3)Math.floor(x) of a number, or of a quotient whose divisor can be 0: Math.floor(x) | 0
bytes[i] and text.charCodeAt(i), which can be undefined or NaN: bytes[i] | 0
[cursor.line] = pair, for (cursor.line of lines) and advance(cursor, ...rest), which the build can't track: store the value in a number variable first, then n | 0
  • A call that the build can't see gets a guard, like every other type. numbers.map(half) passes half around as a value, so the build doesn't know what it's called with. Such a call guards each int32 argument, and 1.5 takes the slow version of the function. So does a call through an any or from a JavaScript file.
  • Untyped code can store anything in a field. Storing 1.5 in cursor.column through an any works as in plain JavaScript, and the field reads 1.5 afterwards. Typed code that reads cursor.column from that object falls back to its slow path.
  • Declare a member int32 in every type that the object is used as, such as a class and the interface it implements. If the interface says number, a store through the interface is a store of a number, and 1.5 ends up in the field the same way. The same goes for an object that was created as { column: number } and is then passed as { column: int32 }: TypeScript allows that, and the build doesn't look inside the object.
  • TypeScript's type 0 also holds -0. const zero = -0 has the type 0, and so has n behind n === 0. The build takes that type for the whole number and guards it like every other type: a -0 that arrives takes the slow version of the function and stays a -0.
  • int32 only counts when it's the whole type of a variable, field, parameter or return value. Inside another type, as in int32[] or Map<string, int32>, it's a number. column?: int32 is an int32 or undefined.

You rarely need int32 for a local variable. The compiler infers the range of a loop counter by itself. int32 pays off in the fields of objects that you create by the million, where it halves the size of a number.

See also#