MetaScript
Beta
Sound Types

Survive into the binary

TypeScript erases types at the editor boundary. MetaScript types decide memory layout, narrow every case, and refuse to lie.

The three mechanisms

  • struct — objects that copy like numbers do. TypeScript objects always share by reference. MetaScript adds struct, where assignment copies (like a primitive does). Useful for small records — a point, a color, a rectangle — where sharing would surprise you.
  • match covers every case, or doesn't compile. TypeScript's switch lets you forget a branch unless you write the assertNever trick. MetaScript's match makes exhaustiveness mandatory — and works the same way on enum, boolean, and string union tags. Result<T, E> falls out of this naturally: it's just a union of two branches that match covers like any other.
  • JsonValue, Row, Document — typed carriers instead of any. TypeScript's any is a global escape hatch; once it touches a binding, that branch of your code stops being type-checked. MetaScript flips it: each type opts in to dynamic access by defining a getDynamicField method. JSON parsing returns JsonValue, Postgres queries return Row, BSON parse returns Document. You chain data.user.profile.name like in JS, but the type stays specific the whole way. To leave, assign to a typed binding: const name: string = data.user.profile.name. The dynamic ends there.

By the end you'll know when to pick struct vs interface vs class, why match forces you to cover every case (and how Result<T, E> falls out of that for free), and how to handle dynamic data without any leaking across your code.

The shared TypeScript surface (generics, narrowing, inference, mapped types, optional chaining, tuples) works unchanged — see TypeScript Compatibility for the parts this page glosses over.

struct, interface, class — where data lives, and how it's packaged

Two questions decide which keyword to reach for: where does the data live (heap or stack), and does behavior travel with it (methods and a constructor inside the body, or attached later from outside).

// All three declare a type with fields. Only `struct` is new in MetaScript.

interface User { id: string; name: string; }              // heap, shared

class Counter {                                            // heap, shared, with methods
  count: number;
  constructor() { this.count = 0; }
  tick(): void { this.count += 1; }
}

struct Vec2 { x: float64; y: float64; }                   // inline, copied on assignment
interfaceclassstruct
Liveson the heapon the heapinline (stack when possible)
On assignmentshared (a === b)shared (a === b)copied (independent)
Constructorobject literalnew Foo(...) + bodyobject literal
Methodsvia extension functionsinside the bodyvia extension functions
Use forplain data recordsdata + behavior togethersmall value records
ExamplesUser, Session, OrderCounter, Database, PoolVec2, Color, Rect, Duration

Two rules of thumb:

  • Where does it live? If having a === b matter would surprise you, use struct. A Vec2 { x: 1, y: 2 } should behave like a number — copying it shouldn't create a hidden link between caller and callee. Anything you'd want to update in one place and see updated everywhere is interface or class.
  • Does behavior travel with the data? Pick interface when the type is just a record (methods, if any, attach later via extension functions — same as arr.push() in JavaScript). Pick class when methods and construction logic should ship with the type, with TypeScript's new/private/public ergonomics on top.

Sized number types — for when number isn't enough

TypeScript's number is a 64-bit float. It loses precision past 2^53, and it can't express "this must be a 16-bit unsigned port", "this is exactly one byte", or "this is a 32-bit float for the GPU". MetaScript adds precise integer and float types alongside number:

const port: uint16 = 8080;
const flags: uint32 = 0xFF00FF00;
const fileSize: int64 = 4_294_967_296;   // > 32 bits, no precision loss
const masked = flags & 0x00FF00FF;        // bitwise ops stay in 32 bits
const ratio: float32 = 0.5;               // 32-bit float for GPU buffers / C APIs

Smaller integers fit into bigger ones automatically (an int8 can pass where an int32 is expected). Going the other way — int32 into int8, or number into any sized type — requires an explicit cast. The conversion happens where you write it, not silently inside an expression. number and the sized number types never mix without a cast.

Unions, declared once over a tag

Your existing TypeScript discriminated unions work as is. The new construct is match — exhaustive by default, where switch would let you forget a branch:

// Given: type Shape = { kind: "circle"; radius: number } | { kind: "square"; side: number };
function area(s: Shape): number {
  return match (s.kind) {
    "circle" => 3.14159 * s.radius * s.radius,
    "square" => s.side * s.side,
  };
}

The shorter shape — match over an enum or a boolean

When the tag is an enum value or a boolean, MetaScript lets you declare the union as a match over the tag, with each branch declaring its own fields inline:

enum NodeKind { NumLit, StrLit, BinExpr }

type NodeData = match (kind: NodeKind) {
  NodeKind.NumLit  => { value: number },
  NodeKind.StrLit  => { value: string },
  NodeKind.BinExpr => { op: string; left: Node; right: Node },
};

const ok: NodeData  = { kind: NodeKind.NumLit, value: 42 };       // ✓
const bad: NodeData = { kind: NodeKind.NumLit, op: "+" };          // ✗ compile error

Both shapes catch bad at compile time — that part is the same as TypeScript. What's different is the declaration shape: in TypeScript you'd list each variant by hand ({ kind: K.NumLit; value: number } | { kind: K.StrLit; value: string } | { kind: K.BinExpr; ... }). The match form keeps the tag named once at the top, declares the fields per branch underneath, and stays readable as variants pile up. It also handles a boolean tag natively — which is how Result<T, E> is declared:

Result<T, E> is just a normal union

Result<T, E> isn't a built-in primitive — it's a regular union of two cases, tagged by an ok boolean. You could write the same shape yourself for any pair of types:

type Result<T, E> = match (ok: boolean) {
  true  => { value: T },
  false => { error: E },
};

That's why if (r.ok) narrows r.value (and else narrows r.error) — same mechanism as TypeScript's narrowing. The try operator either unwraps the success branch or returns the error immediately:

function divide(a: number, b: number): Result<number, string> {
  if (b === 0) return Result.err("division by zero");
  return Result.ok(a / b);
}

function pipeline(x: number): Result<number, string> {
  const a = try divide(x, 2);          // unwraps, or returns the error early
  const b = try divide(a, 3);
  return Result.ok(b);
}

const v = try divide(10, 0) catch 0;   // explicit fallback — v === 0

throw / try / catch still work as in TypeScript — Result<T, E> is an alternative for typed error flows, not a replacement.

distinct type — types that don't mix even when their shapes match

In TypeScript, UserId = number and OrderId = number are interchangeable — both are just number. distinct type makes them incompatible, with no runtime overhead:

distinct type UserId  = number;
distinct type OrderId = number;

function load(id: UserId): User { ... }

const u: UserId  = 42;
const o: OrderId = 42;
load(u);    // ✓
load(o);    // ✗ compile error — OrderId is not UserId

When the program runs, both u and o are just integers — the distinction lives only in the type checker.

What the compiler refuses to let you do

In TypeScript, the type checker can be tricked. any, as, declaration merging, and JSON.parse are escape hatches that trust whatever you write at compile time, then crash at runtime:

// TypeScript — compiles fine, crashes when you read user.name
const user: User = JSON.parse(response);

MetaScript closes those gaps:

TypeScriptMetaScript
anyremoved — use unknown, then narrow before reading
undefinedremoved — null is the one null value
as (unchecked cast)only widens; narrowing requires a real guard
Non-exhaustive switchmatch requires every case
JSON.parse(): anyJSON.parse<T>(): Result<T, ParseError>
Declaration mergingremoved — one declaration per name
Ambient globalsevery external symbol is declared with extern
Numeric ↔ string enumuse a string literal union: type Color = "red" | ...

If TypeScript code compiles in MetaScript at all, it compiles into a smaller, safer subset.

Dynamic data, when you really want it

Strict types are right for most code but wrong for inherently shapeless data — a JSON blob from an API, a database row, JSX props from a third-party library. MetaScript lets a type opt in to dynamic field access by defining a special method. The type stays distinct (a JsonValue, not a generic any), and you leave the dynamic world only through an explicit, typed call.

// JSON.parse three ways:

// 1. Strict — verified parse, errors surface as values
const parsed: Result<User, ParseError> = JSON.parse<User>(response);
if (!parsed.ok) return handle(parsed.error);
const user = parsed.value;        // user: User, guaranteed by the parser

// 2. Ergonomic — `try` unwraps, or returns the error upstream
const user = try JSON.parse<User>(response);

// 3. Dynamic — opt in to JS-style chaining without leaking `unknown`
const data: JsonValue = JSON.parse(response);
const name: string = data.user.profile.name;

Each chain step returns another JsonValue. Missing fields don't throw — they return a JsonValue representing null. You leave either by assigning to a typed binding (the typed default is used for missing fields) or by calling a typed extractor with an explicit default:

const name: string = value.nested.field; // fallback to "" if not exist
const name = value.nested.field.asString("anonymous"); // fallback "anonymous" if not exist
const isSuccess: boolean = value.nested.success; // false if not exist
const isSuccess = value.nested.success.asBoolean(true); // true if not exist
const age = field.asInt32(0);
const score = field.asNumber(0);

Strict types stay strict. Dynamic types announce themselves in their declared type, not hidden inside an any.

Every type has a known shape

MetaScript compiles to both native C and JavaScript. Every type has a known representation in each — there's no hidden boxing layer between what you write and what gets emitted. That's why a minimal native binary is around 80 KB instead of 80 MB.

MetaScriptC backendJavaScript backend
numberdoublenumber
int32int32_tnumber (range-checked)
int64int64_tBigInt
stringmsString (copy-on-write)string
uint8[]msUint8ArrayUint8Array
booleanboolboolean
struct TC struct T (inline)object
interface Trefcounted heap pointer to Tobject
Result<T,E>tagged union of value and error{ ok, value? , error? }
cstringconst char*string
unknownvoid*unknown

What's not in MetaScript (on purpose)

  • any — use unknown; you have to narrow it before reading
  • undefinednull is the one null value
  • String-valued enums like enum Color { Red = "red" } — write type Color = "red" | "green" instead
  • Declaration merging — one declaration per name
  • Ambient globals — every external symbol is declared with extern
  • Implicit number conversionint32 and number don't mix without a cast
  • Object as a type — use interface, struct, Record<K, V>, or unknown

Each omission removes a class of bugs that no lint rule could ever close.

Next Steps