Language surface: Mochi onto TypeScript 5.6 / ES2024
Author: research pass for MEP-52 (Mochi to TypeScript / JavaScript transpiler).
Date: 2026-05-23 (GMT+7).
Sources: docs/features/*.md, docs/index.md, docs/common-language-errors.md,
mcp/cheatsheet.mochi, ROADMAP.md, examples/v0.2-v0.7, the normative
security specs docs/security/threat-model.md and docs/security/memory-safety.md,
the TC39 finished-proposals list (ES2024 was ratified by the TC39 General
Assembly in June 2024), the TypeScript 5.6 release notes (Sept 2024), the
TypeScript 5.7 release notes (Nov 2024), the TypeScript 5.8 beta notes
(Feb 2025), the Node.js 22 LTS announcement (April 2024), the Deno 2.0
launch notes (Oct 2024), the Bun 1.0 launch notes (Sept 2023) and Bun 1.1
release notes (April 2024), the npm CLI 10.x release notes (Sept 2023),
the sibling MEP-45 note 01 (C target), MEP-46 note 01 (Erlang/BEAM),
MEP-47 note 01 (JVM), MEP-48 note 01 (.NET), MEP-49 note 01 (Swift),
MEP-50 note 01 (Kotlin), MEP-51 note 01 (Python), whose section structure
this note deliberately mirrors so all eight backends can be diffed line
for line.
This note records the user-visible language surface that the TypeScript target must faithfully reproduce. It is written from the spec downward and ignores the existing Go runtime (vm3), the vm3 bytecode, the C target under MEP-45, the Erlang/BEAM target under MEP-46, the JVM target under MEP-47, the .NET target under MEP-48, the Swift target under MEP-49, the Kotlin target under MEP-50, the Python target under MEP-51, and any other backend implementation. The goal is a transpiler design that would be correct against the language, not against the present implementations.
The surface decomposes into the same eight orthogonal sub-languages identified in the prior notes: (1) the value core, (2) the function and method core, (3) the collection core, (4) the algebraic-data-type core, (5) the query DSL, (6) the stream and agent core, (7) the logic-programming core, and (8) the AI and FFI shells. Each section below names every form a Mochi program can write, then states a lowering obligation the TypeScript backend must honour.
Where MEP-45 maps Mochi types to C struct plus helper-function pairs,
MEP-46 maps them to BEAM terms (atoms, tagged tuples, maps, binaries,
funs, PIDs), MEP-47 maps them to JVM values directly via bytecode,
MEP-48 maps them to .NET values, MEP-49 maps them to Swift values
(Int64, Double, structs, enums with associated values, actors,
AsyncStream), MEP-50 maps them to Kotlin values (Long, Double,
data class, sealed interface, custom actor with Channel<Message>,
Flow<T>), MEP-51 maps them to Python values (arbitrary-precision
int, IEEE-754 float, dataclass records, PEP 695 type alias sums,
asyncio.Queue agents), this note maps them to TypeScript / ECMAScript
2024 values: bigint (arbitrary precision) or number (IEEE 754
double) per monomorphisation, boolean, string (UTF-16 internally,
len(s) returns code-point count), Uint8Array for bytes,
readonly T[] / T[] for lists, Map<K, V> (insertion-ordered by
spec), Set<T> (insertion-ordered by spec, with ES2024 set methods),
frozen-property classes for records, discriminated unions via
literal-tagged type Foo = A | B | C for sum types, T | null for
options, custom MochiResult<T, E> discriminated union for errors,
custom agent class wrapping AsyncIterableQueue<Message> plus an
AbortController for supervision, AsyncIterable<T> for streams, and
(args) => R for function types. The target IR is discussed in
05-codegen-design (the default path emits TypeScript source via a
Mochi-internal CST builder, then a prettier 3.x formatter pass for
layout); the runtime is the Web platform plus a thin mochi_runtime
npm package (see 04-runtime).
Throughout, "TypeScript" means TypeScript 5.6.0 (released
2024-09-09) and later, and "ECMAScript" means ES2024 (the 15th
edition of ECMA-262, ratified June 2024). TypeScript 5.5 (June 2024)
is not the floor because TS 5.6 introduces --noUncheckedSideEffectImports
and --rewriteRelativeImportExtensions, both of which the build
pipeline depends on for .ts source imports surviving emit cleanly.
Node.js 20 LTS (which became LTS in October 2023 and goes EOL April
2026) is not the floor because it lacks native Promise.withResolvers
(ES2024, requires Node 22+). Node.js 22 LTS (April 2024 release, became
LTS October 2024) is the floor; older Node runtimes are out of scope.
Deno 1.x is not the floor because Deno 2.0 (October 2024) shipped
npm-package interop, the deno.json workspace model, and JSR
(jsr.io) publishing. Bun 1.0 (September 2023) is the floor for Bun,
with Bun 1.1 (April 2024) the recommended baseline. Browsers: the
floor is "browsers shipped after April 2024" (Chrome 124+, Firefox
125+, Safari 17.4+), which is the cohort that ships Promise.withResolvers
natively. Older browsers receive a polyfill via the runtime package.
1. Value core
1.1 Bindings
Mochi has exactly two binding forms.
-
let name = expr, immutable. Re-assignment is a compile-time error, not a runtime panic. TypeScript has two relevant binding forms:const(immutable binding, mutable referent) andlet(mutable binding). The Mochi-emitted form is:const name: number = expr;TypeScript's
constis enforced at compile time by the type checker. Mochi'sletsemantic ("the binding may not be reassigned") maps to TypeScript'sconstexactly. Note that TypeScriptconstdoes not deep-freeze the value (aconstarray can still have elements mutated); Mochi's immutability discipline for collections is enforced separately viareadonlymodifiers on collection types (see §3) and viaObject.freeze()calls inserted by the runtime at record construction sites (see §2.3). -
var name = expr, mutable. Re-assignment is unrestricted within the variable's lexical scope. Emitted as a TypeScriptletbinding:let name: number = expr;Note the deliberate cross-language inversion: Mochi
letbecomes TypeScriptconst, Mochivarbecomes TypeScriptlet. The Mochi-var-to-TypeScript-letmapping is mechanical; the Mochi-let-to-TypeScript-constmapping is the one to remember. The emitter never emits the TypeScriptvarkeyword (function-scoped hoisting semantics, deprecated in style guides since ES2015). Both Standard and Airbnb style guides forbidvar; ESLint'sno-varrule is on by default in@typescript-eslint/recommended.
Mochi blocks are expressions in the sense that the last expression is
the block's value. TypeScript / JavaScript blocks are statements (only
arrow function bodies and a handful of expression contexts evaluate to
values). The backend lowers block-valued constructs in one of three
ways: (a) for a one-line conditional, into a TypeScript conditional
expression cond ? a : b; (b) for a more complex block, into an
immediately-invoked arrow function (() => { ... return v; })()
returning the block value; (c) for sum-type matches with no side
effects in arms, into a switch (x.kind) statement with assignment of
the result inside each arm. See 05-codegen-design §6 for the full
block-lowering table.
A binding may carry an explicit type: let x: int = 0 becomes
const x: bigint = 0n (or const x: number = 0 if monomorphisation
proves the value fits in i53; see §1.2). The type annotation is
attached to the declaration, not on a separate line; TypeScript's
"PEP 526-equivalent" is just standard variable declaration syntax.
Mochi supports destructuring at let:
let [a, b] = [1, 2]
let {"name": n, "age": age} = {"name": "Ana", "age": 22}
TypeScript / ECMAScript natively supports both array and object destructuring (ES2015). The lowering is direct:
const [a, b]: [bigint, bigint] = [1n, 2n];
const { name: n, age }: { name: string; age: bigint } =
{ name: "Ana", age: 22n };
For array destructuring with an arity check (Mochi's let [a, b] = xs panics if xs.length != 2), the lowering adds a runtime guard
because the destructure itself does not check arity (excess elements
are dropped, missing elements become undefined):
if (xs.length !== 2) {
throw new MochiPatternError(`arity expected 2, got ${xs.length}`);
}
const [a, b] = xs;
The MochiPatternError class is exported from mochi_runtime/errors.
For object destructuring, the key strings are statically known at
emit time, so the type system narrows correctly. If the source map
type is Record<string, unknown>, the destructured names take type
unknown and an explicit cast (a type assertion as bigint) is
emitted at the use site, with --noPropertyAccessFromIndexSignature
preventing accidental property access. See 06-type-lowering §7.
For record types the backend uses property access since classes do not destructure by position (they destructure by key just like plain objects):
const n: string = person.name;
const age: bigint = person.age;
Scoping is lexical and block-based. TypeScript's const and let
are block-scoped (ES2015 semantics), matching Mochi's block scoping.
This is unlike Python (function-scoped) and unlike pre-ES2015
JavaScript (function-scoped var). No rename pass is required for
inner shadowing; the emitter can reuse the Mochi-level name directly.
Reserved-word collisions are handled with a trailing underscore: Mochi
class becomes TypeScript class_, Mochi function becomes
TypeScript function_, Mochi import becomes TypeScript import_.
The full list (TypeScript 5.6 has 67 reserved words: 36 keywords, 11
future-reserved, 4 strict-mode reserved, 16 contextual keywords) is
in §1.6 and 06-type-lowering §2.
1.2 Primitive types
Surfaced by the docs and the cheatsheet, with the TypeScript-side representation:
| Mochi | Width / semantics | TypeScript lowering |
|---|---|---|
int | 64-bit signed integer (inferred from integer literals) | bigint (arbitrary precision) OR number (when monomorphisation proves the value fits in [-(2^53-1), 2^53-1]) |
float | 64-bit IEEE 754 double | number (the only floating-point type in JS) |
bool | true / false | boolean |
string | UTF-8 text, indexable as code points, immutable | string (UTF-16 internal; len(s) is code-point count via a runtime helper or [...s].length) |
bytes | immutable byte sequence | Uint8Array (the ECMAScript-standard byte container) |
time | absolute timestamp | Date (millisecond precision) or Temporal.Instant (TC39 Stage-3, ES2025+) wrapped in a MochiTime class |
duration | time interval | number (milliseconds) wrapped in a MochiDuration class, with Temporal.Duration planned for ES2025 |
image (preview) | binary blob | Uint8Array wrapped in a MochiImage class |
TypeScript / JavaScript has two numeric primitive types and they do
not mix: number is IEEE 754 double-precision (53 bits of integer
precision), and bigint is arbitrary-precision integer (ES2020).
The two cannot be mixed in arithmetic without an explicit conversion;
1n + 1 throws TypeError: Cannot mix BigInt and other types, use explicit conversions. Mochi's int is documented as 64-bit signed,
which overflows JS's safe-integer range at 2^53. The emitter
monomorphises Mochi int based on IR analysis: if the IR proves a
value (and all operations on it) stay within [-(2^53-1), 2^53-1], the
emitted type is number and arithmetic uses +, -, *,
Math.trunc(a / b) (true integer division), ((a % b) + b) % b
(floor remainder); if the IR cannot prove i53 containment, the emitted
type is bigint and arithmetic uses +, -, *, / (BigInt's /
is integer division), % (BigInt's % is truncated remainder, not
floor; the emitter inserts ((a % b) + b) % b for floor semantics).
The monomorphisation pass runs at the aotir level (see
05-codegen-design §8). The default for ambiguous cases is
bigint, because correctness beats performance: a Mochi program that
silently corrupts integer arithmetic due to i53 overflow is a worse
failure mode than slow bigint arithmetic. The --prefer-number
flag inverts the default (use number everywhere, emit a runtime
overflow check on every arithmetic op); off by default. See
02-design-philosophy §6 for the i53-versus-bigint cost analysis.
Both type checkers (tsc and the JetBrains TypeScript engine bundled
in WebStorm) enforce the bigint/number separation strictly. The
emitter never produces mixed-mode arithmetic; if a bigint and a
number must interact (e.g., when reading a JSON value that decoded
to a number and the Mochi type is int), the emitter inserts an
explicit BigInt(num) or Number(bn) cast at the boundary, with the
documented loss of precision recorded in a comment.
Implicit numeric conversions are not allowed in Mochi. int + float is a Mochi type error; the program must float(x) first.
TypeScript's number + number (where one operand is float and the
other is int-stored-as-number) succeeds silently with no warning,
because both are number at the JS level. The Mochi type checker
catches the mismatch upstream, so the emitter never attempts the
mixed expression. For the bigint-vs-number case, TS catches the
mismatch (the types are distinct), giving us a safety net.
Integer overflow under the bigint path: arbitrary precision, no
overflow. Under the number path: silent IEEE 754 rounding for
values beyond i53. The emitter chooses bigint whenever the IR
cannot prove i53 containment, so the default path never silently
overflows. For the --prefer-number opt-in, the emitter inserts a
runtime helper mochiCheckI53(x) that throws MochiOverflowError if
x exceeds the safe range. See 06-type-lowering §5.
1.3 Operators
Arithmetic + - * / %; comparison == != < <= > >=; boolean
&& || !; membership in; string concatenation overloads +.
| Mochi | TypeScript (number path) | TypeScript (bigint path) |
|---|---|---|
a + b (int) | a + b (53-bit precision) | a + b (bigint) |
a + b (float) | a + b (IEEE NaN propagates) | N/A |
a + b (string) | a + b (string concatenation) | N/A |
a + b (list) | [...a, ...b] (spread, fresh array) | N/A |
a - b | a - b | a - b |
a * b | a * b | a * b |
a / b (float) | a / b (true division returns IEEE float) | N/A |
a / b (int) | Math.trunc(a / b) (truncated division; emitter wraps to floor when Mochi requires floor) | a / b (BigInt division is truncated; emitter wraps to floor when Mochi requires floor) |
a % b | ((a % b) + b) % b (floor remainder; JS % is truncated remainder by default) | ((a % b) + b) % b |
a == b (primitive) | a === b (strict equality, no type coercion) | a === b |
a == b (record/object) | structural via mochiEqual(a, b) runtime helper | N/A |
a != b | a !== b | a !== b |
a < b, <=, >, >= | numeric: native; string: a < b uses UTF-16 code-unit ordering by default, but the emitter wraps with mochiStrCompare(a, b) for code-point ordering | numeric: native |
a && b | a && b (JS && returns the first falsy operand or the last operand; type checker narrows to boolean when both operands are typed boolean) | N/A |
a || b | a || b | N/A |
!a | !a | N/A |
x in xs (list) | xs.includes(x) (since ES2016) | N/A |
x in m (map) | m.has(x) | N/A |
x in s (set) | s.has(x) | N/A |
JavaScript's == and != perform type coercion ("ToPrimitive" plus
"ToNumber" by default), which can produce surprising results: 0 == "" is true, "1" == 1 is true, null == undefined is true.
The emitter never emits the loose == / != operators; it
always emits === / !==. ESLint's eqeqeq rule is on by default
in our config and would fail the build if == ever appeared.
JavaScript's && and || return one of the operands (not always a
boolean). For example, 1 && 2 === 2, 0 || 3 === 3. Mochi's &&
and || always return bool. The mismatch only matters when the
result is bound to a non-bool variable (which Mochi rejects at
type-check time). The backend never has to coerce because Mochi's
type checker constrained the result type to boolean upstream.
JavaScript's in operator tests for property existence on an
object ("x" in {x: 1} returns true). This is not what Mochi's
x in xs means (membership in a list). The emitter uses
Array.prototype.includes(x) (ES2016) for list membership,
Map.prototype.has(x) for map key membership, and
Set.prototype.has(x) for set membership. The bare in operator is
never emitted in user code.
a == b for record types lowers to a mochiEqual(a, b) runtime
helper that walks structurally. JavaScript has no __eq__ equivalent;
=== on two distinct objects is always false even if they have the
same fields. Mochi's record-equality contract requires field-by-field
comparison. See 06-type-lowering §4.
1.4 Strings as read-only code-point sequences
let text = "hello"
print(text[1]) // "e"
for ch in text { ... }
Indexing yields a 1-character string (not a code point integer). Iteration yields 1-character strings in code-point order. JavaScript / TypeScript strings are UTF-16 internally: each JavaScript string is a sequence of UTF-16 code units, with characters outside the Basic Multilingual Plane represented as surrogate pairs (two code units). This produces several subtle mismatches with Mochi's specified semantic:
s.lengthreturns the code unit count, not the code point count. For an emoji like"\u{1F600}"(grinning face, code point U+1F600),s.length === 2because the character is stored as a surrogate pair. Mochi'slen(s)returns 1 for the same string.s[i]returns the i-th code unit as a single-character string, which may be a lone surrogate (illegal UTF-16) for emoji or astral-plane characters.s.charAt(i)is identical tos[i](legacy method, same semantics).s.codePointAt(i)returns the code point at the i-th code unit position, which is correct for code-point reading but the index is still in code units. Skipping forward by code points requires walking and watching for surrogates.
The Mochi emitter inserts a runtime helper layer:
mochiStrLen(s)returns[...s].length(the array-spread idiom iterates by code point, giving correct count). For ASCII-heavy strings this is O(n); the runtime caches the result on a hidden weakmap-keyed property for repeated calls on the same string.mochiStrIndex(s, i)returns the i-th code point as a 1-character string, via[...s][i]. Same O(n) per call; runtime caches the iterator array for repeated indexing into the same string.for ch in textlowers tofor (const ch of text) { ... }(thefor...ofiteration of a string yields code-point characters natively in ES2015+; this is correct without a helper).
For HTTP, JSON, file I/O the conversion to bytes uses
TextEncoder.encode(s) which returns a Uint8Array of UTF-8 bytes.
This is a one-pass O(n) conversion. TextDecoder.decode(bytes) is
the reverse direction; both are spec-standard Web APIs available in
Node 22, Deno 2, Bun 1.1, and all evergreen browsers. The encoder is
SIMD-optimised on modern V8 (Node 22 ships V8 12.4, which has
auto-vectorised UTF-8 encoding for ASCII strings).
The cost of UTF-16 internal storage compared to Python's PEP 393
variable-width is the surrogate-pair walk: every code-point operation
on a string containing characters outside the BMP pays an O(n) walk.
For text-heavy workloads this is the second-largest performance cost
of the TypeScript target relative to Python (after the bigint-versus-
number choice for integers). For the vast majority of Mochi
programs that process ASCII or BMP-only text, the cost is zero (the
spread idiom shortcuts to code-unit count when no surrogates are
present, since V8 13.0+ in Node 23 optimised this path; Node 22 ships
V8 12.4 which has the same optimisation).
See 02-design-philosophy §6 for the UTF-16 cost analysis and the comparison against Python's PEP 393, Kotlin's UTF-16 + StringBuilder, and Swift's grapheme-cluster default.
1.5 Literals
Integer literals (42); floating literals (3.14); boolean
(true/false); string with C-style escapes; triple-quoted
multi-line strings (TypeScript template literals); list [...]; map
{key: val, ...}; set {a, b, c}; record constructor T { field: val }.
The set literal {a, b, c} is distinguished from the empty map
literal {} by the absence of : after the first element. The
grammar must keep these unambiguous; the TypeScript lowering picks
the right constructor accordingly. Note that TypeScript's {} is
the type of "any non-nullish value" (or an empty object literal,
depending on context); neither maps to Mochi's empty map. The
emitter uses new Map() for an empty map and new Set() for an
empty set.
Lowering forms:
| Mochi | TypeScript |
|---|---|
42 (int, fits i53) | 42 |
42 (int, bigint path) | 42n (BigInt literal suffix) |
3.14 | 3.14 (JS number literal, IEEE 754 double) |
true / false | true / false |
"hello" | "hello" |
[1, 2, 3] | [1n, 2n, 3n] (bigint path) or [1, 2, 3] (number path) |
"""multi\nline""" | `multi\nline` (template literal; TS template literals support multi-line and ${...} interpolation) |
{"a": 1, "b": 2} | new Map<string, bigint>([["a", 1n], ["b", 2n]]) (Map constructor with entry array; insertion order guaranteed by ES2015 spec) |
{1, 2, 3} (set) | new Set<bigint>([1n, 2n, 3n]) (Set constructor; insertion order guaranteed by ES2015 spec) |
Book { title: "X", pages: 10 } | new Book("X", 10n) or Book.of({ title: "X", pages: 10n }) (record class with factory) |
JavaScript's plain object literal {a: 1, b: 2} is not what Mochi
map means. The plain object is a record (string-keyed property
bag), not a map. Plain objects do not have a has(key) method
(only the in operator, which inherits up the prototype chain), they
do not have an iteration order guarantee for integer-string keys
(integer-like string keys are iterated first, then other strings in
insertion order, per ES2015 OrdinaryOwnPropertyKeys), and they have
the prototype-pollution hazard (a key named "__proto__" modifies
the prototype). The emitter always uses Map<K, V> for Mochi maps,
never plain objects. Map:
- has a
has(key)method with O(1) lookup; - guarantees insertion-order iteration for all key types (not just strings);
- has no prototype-pollution hazard (keys are stored in a separate slot, not on the object itself);
- supports non-string keys (numbers, bigints, objects, symbols).
Similarly, the emitter uses Set<T> for Mochi sets, never plain
objects-as-sets (which were a common JS idiom pre-ES2015 but are no
longer canonical).
ECMAScript 2015 introduced Map and Set with the insertion-order
iteration guarantee. ECMAScript 2024 adds new methods on Set:
union(other), intersection(other), difference(other),
symmetricDifference(other), isSubsetOf(other),
isSupersetOf(other), isDisjointFrom(other). These map directly to
Mochi's set operations (see §3), making the TS target's set
implementation one of the cheapest among all eight backends.
The frozen record class is the default record representation:
class Book {
readonly title: string;
readonly pages: bigint;
constructor(title: string, pages: bigint) {
this.title = title;
this.pages = pages;
Object.freeze(this);
}
static of(props: { title: string; pages: bigint }): Book {
return new Book(props.title, props.pages);
}
}
readonly on class fields is a TypeScript-only modifier (not part of
JavaScript); it prevents assignment to the field from outside the
constructor and is enforced at compile time by tsc. Object.freeze()
adds runtime enforcement: any attempt to reassign a frozen object's
property throws TypeError in strict mode (and silently fails in
sloppy mode, but Mochi-emitted code is always strict due to ES modules
implying strict mode). See §4 for the full ADT discussion.
The static of(props) factory takes a plain object with the same
fields and constructs the record. The factory is the canonical Mochi
construction form, matching the Mochi syntax Book { title: "X", pages: 10 }. Direct construction via new Book("X", 10n) is also
emitted when the call site is positional.
1.6 Identifier mangling
TypeScript identifiers may begin with letter, $, or _ and continue
with letter / digit / $ / _. Mochi identifiers are stricter, so
every Mochi identifier is a legal TypeScript identifier until it
collides with a TypeScript reserved word. TypeScript 5.6 reserves 67
words total (a superset of JavaScript reserved words because TS adds
type-system keywords like type, interface, keyof, infer); the
emitter mangles collisions with a trailing underscore:
| Mochi name | TS name (after mangling) |
|---|---|
class | class_ |
function | function_ |
import | import_ |
export | export_ |
new | new_ |
delete | delete_ |
void | void_ |
typeof | typeof_ |
instanceof | instanceof_ |
in | in_ |
of | of_ |
yield | yield_ |
async | async_ |
await | await_ |
let | let_ |
const | const_ |
var | var_ |
type | type_ |
interface | interface_ |
enum | enum_ |
extends | extends_ |
implements | implements_ |
keyof | keyof_ |
infer | infer_ |
as | as_ |
satisfies | satisfies_ |
Mochi variables that collide with a TypeScript built-in (Array,
Object, String, Number, Boolean, Date, Map, Set,
Promise, Symbol, Iterator, Math, JSON, console, globalThis)
are mangled as well, even though TypeScript allows shadowing of
built-ins. The mangling preserves the no-shadow lint rule (eslint no-shadow-restricted-names) and avoids cognitive load for readers.
The full list (TypeScript 5.6 has approximately 120 built-in global
names accessible without import) is in 06-type-lowering §3.
Mochi package paths mathutils/extra produce TypeScript module
src/generated/mathutils/extra.ts for user code (configurable via
--ts-module-prefix; default src/generated). The generated
segment makes the runtime / user distinction visible in stack traces
and in package.json "exports" graphs. Each Mochi source file
becomes one TypeScript module; Mochi packages become TypeScript
namespaces via the directory structure plus index.ts re-export
files.
Mochi record type names become TypeScript class names in PascalCase,
unchanged (Book becomes Book). On collision with a TypeScript
global (Array, Function, Object, Error, Type, Promise),
the emitter renames Type to Type_ and emits a module-scope alias.
Mochi sum-type variant constructors become TypeScript classes nested
inside a namespace block with the sum type's name, plus a
discriminated-union type alias (PascalCase preserved). Field labels
are preserved verbatim. See §4 ADT lowering.
The mangling is deterministic (05-codegen-design §3) and
reversible via TypeScript line comments (// mochi:source file.mochi:line)
which the emitter writes for every Mochi source line. TypeScript's
source-map machinery is the formal reverse-mapping tool; the emitter
generates .ts.map files alongside every emitted .ts so that
Node's --enable-source-maps flag and the V8 inspector point at the
original Mochi source. See 10-build-system §15.
1.7 Optionality
Mochi has no null at the language level. Optional values are
expressed via the Option<T> sum type. The TypeScript lowering uses
TypeScript's native T | null representation for Mochi
Option<T>. This is the choice the Swift target (MEP-49), Kotlin
target (MEP-50), and Python target (MEP-51) all made, and matches the
TypeScript ecosystem's predominant style (the React community and
the FastAPI-equivalent NestJS community both use T | null).
The decision: the TypeScript target uses T | null for Mochi
Option<T>, not T | undefined. The reasoning:
- TypeScript distinguishes
null(explicitly absent) fromundefined(unset). The two are similar but not identical:JSON.stringify({a: undefined})produces'{}'(omitted), whileJSON.stringify({a: null})produces'{"a":null}'(preserved). The semantic of "the value is absent and the absence is meaningful" matchesnull; the semantic of "I forgot to set this property" matchesundefined. MochiOption<T>means the former, soT | nullis the right match. --strictNullChecks(part of--strict) enforces thatT | nullvalues must be narrowed withx === nullorx !== null(or the nullish-coalescing operators??,?.) before use. This catches the entire class of "null reference exception" bugs at compile time.--exactOptionalPropertyTypes(TypeScript 4.4+, off by default but on in ourtsconfig.json) preventsundefinedfrom sneaking in as a substitute fornull. A property declaredfield?: T(which isT | undefinedby default) cannot be assignedundefinedexplicitly unless declaredfield?: T | undefined. This keeps the distinction rigorous.--noUncheckedIndexedAccessmakes array and map indexing returnT | undefined(the absence isundefined, the absent-from-an-Option case isnull). This separation keeps "the index was out of range" distinct from "the value at the index was the absent option".
Concretely: Mochi Some(x) becomes TypeScript x (the implicit wrap
when assigning to T | null), Mochi None becomes TypeScript null.
For lowering pattern matches that consume Mochi Option:
match opt {
Some(x) => x + 1
None => 0
}
Lowers to a TypeScript conditional expression or if/else:
const result: bigint = opt !== null ? opt + 1n : 0n;
For multi-line arms with statements, the lowering uses an if /
else block. For pure expression arms, the conditional expression is
preferred (it is a single TypeScript expression, type-checks cleanly
via the !== null narrowing, and produces less generated code).
Optional chaining (?.) and nullish coalescing (??) are ES2020
features. The emitter uses them when the expression structure allows:
let n = opt?.name ?? "anon"
Lowers to:
const n: string = opt?.name ?? "anon";
At the FFI boundary, any value coming in from JS code that is typed
as T | null | undefined is funnelled through Mochi Option<T> (the
emitter inserts x ?? null to collapse undefined to null); values
typed as T (non-optional) bypass the wrapper. The type checker
enforces this distinction statically through its narrowing analysis,
so no runtime check is required for pure-TypeScript code paths.
The Mochi Result<T, E> type, by contrast, is not mapped to a
single-type T | E union, because that conflates success and failure
when T and E overlap (e.g., Result<int, int>). The backend emits
a custom MochiResult<T, E> discriminated union, discussed in §4
and §9.
2. Function and method core
2.1 Top-level functions
fun add(a: int, b: int): int { return a + b }
Lowers to a top-level TypeScript function declaration with explicit parameter types and return type:
export function add(a: bigint, b: bigint): bigint {
return a + b;
}
Every Mochi source file produces one TypeScript module file named
after the source file (example.mochi becomes example.ts)
declaring all top-level functions, top-level const bindings, and any
helper classes at module scope. The module exports the public surface
via export keywords on declarations; the backend computes the
export set from the set of Mochi top-level declarations that are not
file-private (priv).
TypeScript / ECMAScript modules execute their top level at import
time. For Mochi top-level expressions that have side effects (e.g.,
let cache = compute_cache()), the lowering emits the side-effecting
expression at module scope and relies on the ES Module specification's
"each module body runs exactly once, the first time it is imported"
contract.
The reason we use module-level top-level declarations and not a wrapping class for the module namespace is that ES modules are the canonical namespace unit, they reduce nesting depth in generated code by one level, they support tree-shaking by static analysis (a bundler can drop unused exports trivially), and they have first-class support in every TS-aware tool. This is the TypeScript equivalent of Swift's "namespacing enum" idiom or Python's top-level declarations.
Mochi return is explicit. The TypeScript lowering preserves
return directly: return e; becomes TypeScript return e;. The
emitter always emits an explicit return at the end of a non-void
function. For void functions (Mochi fun foo(): unit), the
emitter emits function foo(): void { ... } with no explicit return
needed (the implicit undefined return is fine; void in TS means
"the return value is not consumed").
The docs warn there is no implicit tail-call optimisation in
Mochi. ECMAScript 2015 mandated proper tail calls (PTC) for strict
mode, but as of 2026 only Safari/JavaScriptCore implements it; V8
(Node, Chrome) and SpiderMonkey (Firefox) do not. Mochi-emitted
code that recurses deeply will hit the V8 default stack limit (~10000
frames for V8 12.4 in Node 22, configurable via --stack-size) and
throw RangeError: Maximum call stack size exceeded. The backend
detects deep recursion patterns at the Mochi IR level
(05-codegen-design §15) and emits a trampoline helper when the
recursion depth can statically exceed a safe limit. The trampoline
uses an iterative while loop and a stack of work items, preserving
Mochi semantics without the JS stack-limit hazard.
TypeScript supports generics natively (since TS 1.0, 2014):
fun first<T>(xs: list<T>): T { return xs[0] }
Lowers to:
export function first<T>(xs: readonly T[]): T {
const elem: T | undefined = xs[0];
if (elem === undefined) {
throw new MochiBoundsError("first: list is empty");
}
return elem;
}
The xs[0] access returns T | undefined under
--noUncheckedIndexedAccess; the bounds check is mandatory. The
runtime helper MochiBoundsError is exported from
mochi_runtime/errors. Mochi's semantic for xs[0] on an empty list
is "compile-time error if statically empty, runtime panic if
dynamically empty"; the TS emitter implements the runtime panic via
the bounds check.
TypeScript generic syntax has been stable since TS 1.0. PEP 695 in Python (3.12) introduced PEP 695 generic syntax; TypeScript has had the equivalent for a decade. Mochi generics map directly to TS generics with no syntactic transformation beyond the angle-bracket notation.
2.2 First-class function values
let square = fun(x: int): int => x * x
fun apply(f: fun(int): int, value: int): int { return f(value) }
Lower to TypeScript callable types and arrow functions:
const square: (x: bigint) => bigint = (x) => x * x;
export function apply(
f: (x: bigint) => bigint,
value: bigint,
): bigint {
return f(value);
}
TypeScript arrow functions (ES2015) are the canonical first-class
function form. Unlike Python lambdas, TS arrows can have
multi-statement bodies (with { ... } block syntax). The emitter
prefers arrow functions over function declarations for first-class
values because (a) arrows do not have their own this binding,
matching Mochi's no-implicit-this semantic; (b) arrows are more
syntactically concise; (c) the type inference for arrow functions in
TS 5.6+ is strictly better than for function expressions (the
inference engine has special-case handling for arrow-function
parameter type inference from context).
For multi-statement Mochi closures:
const process = (x: bigint): bigint => {
const y = x * 2n;
return y + 1n;
};
For closures that must be invoked from an async context (i.e., that
may await), the arrow is async:
const fetchOne = async (id: string): Promise<Response> => {
const r = await fetch(`/api/${id}`);
return r;
};
The Mochi async keyword maps directly to TypeScript's async. The
type systems agree: calling an async function from a non-async
context produces a Promise<T> object that must be awaited (or
chained with .then(...)); tsc flags forgotten promises as
"Promise-returned value is not awaited" under
@typescript-eslint/no-floating-promises. See 02-design-philosophy
§12 on the coroutine model.
Closures escape freely; captured variables in JavaScript are captured
by reference (not by value), and JavaScript has the classic "loop
variable capture" trap when var is used (since var is
function-scoped). With let and const (block-scoped) the trap
disappears because each loop iteration creates a fresh binding:
// Mochi: for i in range(3) { fns.append(fun() => i) }
const fns: Array<() => bigint> = [];
for (let i = 0n; i < 3n; i++) {
fns.push(() => i); // i is block-scoped, fresh per iteration
}
// fns[0]() === 0n, fns[1]() === 1n, fns[2]() === 2n -- correct
Without the let block-scoping (i.e., if var were used), all three
closures would return 3n (the final value of i). The emitter
always uses let (or const) for loop variables, never var. See
05-codegen-design §16 for the capture policy.
2.3 Methods on type blocks
type Circle {
radius: float
fun area(): float { return 3.14 * radius * radius }
}
A method receives an implicit self; field names inside the block
are unqualified. Lowering: the record is a TypeScript class with
readonly fields plus Object.freeze and the method is an instance
method:
export class Circle {
readonly radius: number;
constructor(radius: number) {
this.radius = radius;
Object.freeze(this);
}
area(): number {
return 3.14 * this.radius * this.radius;
}
static of(props: { radius: number }): Circle {
return new Circle(props.radius);
}
}
TypeScript classes have the standard JS class semantics: constructor,
instance methods, static methods, instanceof testing. readonly
fields are enforced at compile time by tsc and at runtime by
Object.freeze(). The Object.freeze(this) call at the end of the
constructor freezes the instance, preventing any further property
assignment; combined with readonly modifiers, this makes the
record fully immutable.
The combination of readonly + Object.freeze gives a record type
that is:
- compile-time immutable (
obj.field = newValis a tsc error); - runtime immutable (
obj.field = newValthrowsTypeErrorin strict mode); - structurally inspectable (
Object.keys(obj)returns the field names;JSON.stringify(obj)serialises them); - debugger-friendly (Node and Chrome devtools show the fields directly).
TypeScript methods take an implicit this; field access inside the
method body uses this.fieldName. The backend rewrites Mochi
unqualified field references inside methods to this.<field> during
lowering.
For records that need to participate in sorting, the emitter adds a
static compare(a: T, b: T): number method that returns -1/0/+1, and
generated sort() calls pass Class.compare as the comparator. For
records that need JSON serialization, the emitter adds a toJSON()
method that returns a plain object with the fields; JSON.stringify
calls toJSON automatically when present. See 02-design-philosophy
§7 on the choice of class + Object.freeze over Zod, io-ts, Effect's
Schema, and class-validator.
For records with mutable fields (Mochi var field), the lowering
removes the readonly modifier from the field and removes the
Object.freeze call:
export class Counter {
count: bigint;
constructor(count: bigint) {
this.count = count;
// no Object.freeze, fields are mutable
}
}
Mochi's value-semantics contract on records (records are copied by value when assigned or passed to a function) is preserved by:
(a) the default readonly + Object.freeze immutability for records
without var fields, which lets aliasing be safe;
(b) explicit Object.assign(new Counter(0n), { count: newVal }) or
the equivalent factory Counter.of({...this, count: newVal}) calls
at every Mochi mutation site for var records, which preserves
value semantics by producing a fresh instance per mutation.
See 06-type-lowering §4.
2.4 Built-in print
Variadic, prints with default formatting and inserts spaces (cheatsheet:
print("name = ", name, ...)); newline at end.
Lowers to a mochiPrint helper from mochi_runtime/io:
import { print as mochiPrint } from "mochi_runtime/io";
mochiPrint("name =", name);
JavaScript's built-in console.log(*args) is already variadic with
space separators by default and newline termination. Mochi's print
semantics nearly match console.log exactly, with one wrinkle:
Mochi's list, map, set, and record format should match Mochi's
documented format, not Node's util.inspect format (which produces
Map(2) { 'a' => 1, 'b' => 2 } for a Map, not {a: 1, b: 2} as
Mochi specifies). The lowering uses mochi_runtime/io/print instead
of bare console.log:
export function print(...args: unknown[]): void {
const formatted = args.map(formatValue).join(" ");
console.log(formatted);
}
function formatValue(v: unknown): string {
if (v === null) return "nil";
if (typeof v === "string") return v;
if (typeof v === "bigint") return v.toString();
if (Array.isArray(v)) return `[${v.map(formatValue).join(", ")}]`;
if (v instanceof Map) {
const entries = [...v.entries()]
.map(([k, val]) => `${formatKey(k)}: ${formatValue(val)}`)
.join(", ");
return `{${entries}}`;
}
if (v instanceof Set) {
return `{${[...v].map(formatValue).join(", ")}}`;
}
if (typeof v === "object" && v !== null && "toMochiString" in v) {
return (v as { toMochiString(): string }).toMochiString();
}
return String(v);
}
The toMochiString() method on record classes produces the
Book { title: "X", pages: 10 } form. The emitter synthesises
toMochiString for every record class.
See 04-runtime §3 for the mochi_runtime/io module.
3. Collection core
Three primitive containers, all with structural typing:
list<T>, ordered, growable. Lowers to TypeScriptreadonly T[](immutable view) orT[](mutable). Thereadonlymodifier is a type-system-only mark; the underlying object is always a JSArray.map<K, V>, keyed lookup, with insertion-order iteration. Lowers to TypeScriptMap<K, V>(the ES2015 Map class; insertion order guaranteed by spec since 2015).set<T>, unique members, with insertion-order iteration in Mochi semantics. Lowers to TypeScriptSet<T>(the ES2015 Set class; insertion order guaranteed by spec; ES2024 adds the algebraic set operations).
Lowering obligations (full per-type details in 06-type-lowering §1):
-
list<T>is the workhorse. JavaScriptArrayis a dense resizable array with O(1) amortised append, O(1) random access, and O(n) insertion at the head. Element storage is boxed for every JavaScript object reference (includingnumberwhich is stored as a 64-bit double, andbigintwhich is heap-allocated). V8 specialisesArrayinstances with "elements kinds": an array of integers stored in i31 (SMI) form is more compact than a general object array. The emitter does not control this specialisation directly; V8's elements-kind machinery picks based on observed contents. For thenumberpath of Mochiint, V8 may unbox into the SMI form for arrays of small ints; forbigint, no unboxing happens. -
map<K, V>defaults to TypeScriptMap<K, V>. Mochi's iteration order is insertion order. JS'sMaphas guaranteed insertion-order iteration since ES2015. The default lowering is therefore insertion-ordered without a wrapper. Hash-based key lookup is amortised O(1) (V8 implementsMapvia a hash table with open addressing); ordered iteration is O(n). -
set<T>is TypeScriptSet<T>. Mochi'sset<T>is insertion- ordered, matching JSSetexactly. ES2024 adds the algebraic operations:const a = new Set([1, 2, 3]);const b = new Set([2, 3, 4]);a.union(b); // Set { 1, 2, 3, 4 }a.intersection(b); // Set { 2, 3 }a.difference(b); // Set { 1 }a.symmetricDifference(b);// Set { 1, 4 }a.isSubsetOf(b); // falsea.isSupersetOf(b); // falsea.isDisjointFrom(b); // falseThese map directly to Mochi's
union,intersect,except,xor,subset,superset,disjointset operations. Node 22, Deno 2, Bun 1.1, and Safari 17.4+ all ship the ES2024 set methods. For older browsers, the runtime polyfills via a side import. -
All collections are value-semantically copied at language level. JavaScript reference types do not provide this for free (every collection is heap-allocated and aliased on assignment), so the backend emits explicit defensive copies at function-call boundaries. The VM enhancement spec 0951 §1 ("each function call must allocate a fresh copy of any list/map literal") is satisfied by emitting
[...arg](array spread, fresh array),new Map(arg)(Map copy constructor), ornew Set(arg)(Set copy constructor) at every callsite where a collection is passed to a function that may mutate it. The cost is one O(n) copy per call; for hot loops the user can opt into the--no-defensive-copyflag to disable (at the cost of giving up the value-semantics contract). The TypeScript type system helps here:readonly T[]parameters cannot be mutated through the parameter, so the emitter can skip the defensive copy when the callee's parameter is typedreadonly. See 02-design-philosophy §7.
Mutation operations (xs.add(x), m[k] = v) lower to direct
mutating method calls (xs.push(x), m.set(k, v)) when the target
is a var binding (TypeScript let). For let bindings
(TypeScript const), the lowering emits a copy-and-mutate via
helpers in mochi_runtime/collections:
// Mochi: let xs = [1, 2, 3]; xs.add(4)
const xs = [1n, 2n, 3n];
const xs1 = appended(xs, 4n); // returns a fresh array
// Helper:
export function appended<T>(xs: readonly T[], x: T): readonly T[] {
return [...xs, x];
}
The helpers appended, inserting, removing, updating,
mapped, filtered are in mochi_runtime/collections and take the
collection, return a fresh mutated copy, and the caller rebinds. See
06-type-lowering §11.
for x in xs lowers to a TypeScript for...of loop:
for (const x of xs) {
// ...
}
For maps, for (k, v) in m lowers to:
for (const [k, v] of m.entries()) {
// ...
}
JS Map.prototype.entries() returns an iterator yielding
[key, value] pairs in insertion order. The Mochi semantic matches
exactly.
ECMAScript 2024 also introduces Iterator helpers (the
Iterator.from() static method and instance methods map, filter,
take, drop, flatMap, reduce, toArray, forEach, some,
every, find). These map directly to Mochi's query DSL primitives
(see §5). Node 22 ships V8 12.4 which has iterator helpers behind a
flag; Node 22.5+ enables them by default. Deno 2 ships them. Bun
1.1.5+ ships them. Safari 18.4+ ships them. For Firefox 131+ (Oct
2024) they are on by default. For older runtimes, the runtime
polyfills via the core-js/proposals/iterator-helpers import.
3.1 List-of-records
A common Mochi pattern is a list<Record> for dataset-shaped data.
The TypeScript lowering uses readonly T[] directly:
export class Person {
readonly name: string;
readonly age: bigint;
constructor(name: string, age: bigint) {
this.name = name;
this.age = age;
Object.freeze(this);
}
static of(props: { name: string; age: bigint }): Person {
return new Person(props.name, props.age);
}
}
export const people: readonly Person[] = Object.freeze([
Person.of({ name: "Ana", age: 22n }),
Person.of({ name: "Ben", age: 30n }),
]);
The outer Object.freeze makes the array itself immutable (so
people.push(...) throws at runtime). The inner records are already
frozen by their constructors. For very large datasets (millions of
rows), the TypeScript target offers two opt-in alternatives: (a)
typed-array views (Int32Array, Float64Array) when all fields are
numeric, and (b) Apache Arrow tables via apache-arrow (the JS port
of Arrow) when columnar layout is needed for analytics workloads.
Both are opt-in via @dataset annotations on the Mochi record type.
See 08-dataset-pipeline for the data pipeline lowering.