The three mechanisms
struct— objects that copy like numbers do. TypeScript objects always share by reference. MetaScript addsstruct, where assignment copies (like a primitive does). Useful for small records — a point, a color, a rectangle — where sharing would surprise you.matchcovers every case, or doesn't compile. TypeScript'sswitchlets you forget a branch unless you write theassertNevertrick. MetaScript'smatchmakes exhaustiveness mandatory — and works the same way onenum,boolean, and string union tags.Result<T, E>falls out of this naturally: it's just a union of two branches thatmatchcovers like any other.JsonValue,Row,Document— typed carriers instead ofany. TypeScript'sanyis 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 agetDynamicFieldmethod. JSON parsing returnsJsonValue, Postgres queries returnRow, BSON parse returnsDocument. You chaindata.user.profile.namelike 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 assignmentinterface | class | struct | |
|---|---|---|---|
| Lives | on the heap | on the heap | inline (stack when possible) |
| On assignment | shared (a === b) | shared (a === b) | copied (independent) |
| Constructor | object literal | new Foo(...) + body | object literal |
| Methods | via extension functions | inside the body | via extension functions |
| Use for | plain data records | data + behavior together | small value records |
| Examples | User, Session, Order | Counter, Database, Pool | Vec2, Color, Rect, Duration |
Two rules of thumb:
- Where does it live? If having
a === bmatter would surprise you, usestruct. AVec2 { 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 isinterfaceorclass. - Does behavior travel with the data? Pick
interfacewhen the type is just a record (methods, if any, attach later via extension functions — same asarr.push()in JavaScript). Pickclasswhen methods and construction logic should ship with the type, with TypeScript'snew/private/publicergonomics 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 APIsSmaller 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 errorBoth 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 === 0throw / 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 UserIdWhen 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:
| TypeScript | MetaScript |
|---|---|
any | removed — use unknown, then narrow before reading |
undefined | removed — null is the one null value |
as (unchecked cast) | only widens; narrowing requires a real guard |
Non-exhaustive switch | match requires every case |
JSON.parse(): any | JSON.parse<T>(): Result<T, ParseError> |
| Declaration merging | removed — one declaration per name |
| Ambient globals | every external symbol is declared with extern |
Numeric ↔ string enum | use 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.
| MetaScript | C backend | JavaScript backend |
|---|---|---|
number | double | number |
int32 | int32_t | number (range-checked) |
int64 | int64_t | BigInt |
string | msString (copy-on-write) | string |
uint8[] | msUint8Array | Uint8Array |
boolean | bool | boolean |
struct T | C struct T (inline) | object |
interface T | refcounted heap pointer to T | object |
Result<T,E> | tagged union of value and error | { ok, value? , error? } |
cstring | const char* | string |
unknown | void* | unknown |
What's not in MetaScript (on purpose)
any— useunknown; you have to narrow it before readingundefined—nullis the one null value- String-valued enums like
enum Color { Red = "red" }— writetype Color = "red" | "green"instead - Declaration merging — one declaration per name
- Ambient globals — every external symbol is declared with
extern - Implicit number conversion —
int32andnumberdon't mix without a cast Objectas a type — useinterface,struct,Record<K, V>, orunknown
Each omission removes a class of bugs that no lint rule could ever close.
Next Steps
- Memory Model — what happens to
structandinterfacevalues at runtime - Metaprogramming — macros,
quote, and compile-time code values - Concurrency —
spawn,actor,Supervisor, and the unifiedPromise<T>surface - TypeScript Compatibility — the full TS surface that just works