CruftScript language reference
The exhaustive reference for the CruftScript language: how to run a .fts file, program structure, the boundary system, the type system, the standard library, and the diagnostics. It marks precisely what the checker accepts, what runs today, and what is specified but not yet executable.
CruftScript is Cruft's own statically typed language: TypeScript-shaped syntax whose types are kept and enforced at runtime instead of erased. This page is the exhaustive reference for the language surface, the syntax, the boundary system, the type system, the standard library, and the diagnostics. For the ideas behind it, read CruftScript; this page does not repeat the motivation. It is a reference, and it marks precisely what the checker accepts, what runs today, and what is specified but not yet executable.
CruftScript is distinct from TypeScript support, which erases types for ecosystem fidelity. The two share surface syntax and nothing else.
Running CruftScript
CruftScript source lives in .fts files, run directly by the CLI:
$ cruft app.fts # type-check, then run
$ cruft --check app.fts # type-check only, no execution
A .fts file is genuinely checked before it runs; a check failure aborts the load with a diagnostic, and there is no erase-and-run fallback. Exit codes make the outcome scriptable:
| Exit | Meaning |
|---|---|
0 | Checked and ran (or --check passed) |
65 | Check failure: a diagnostic was emitted, nothing ran. Most unsupported body forms land here as UnsupportedBodyExpression (a body as const, a ! in a const initializer, try/throw/for...of/delete/compound-assign) — these are check-time rejections, not exit-70 |
70 | Checked and accepted, but the accepted body is outside the executable lowering (e.g. a general ternary, or a C-style for in a plain function); source was not run |
A .fts module is an ordinary module to the rest of the runtime, so a JavaScript or TypeScript edge can import it:
// edge.mjs, plain JavaScript
import { greet } from "./core.fts";
greet("ada"); // crosses the boundary; validated at the call
The resolution stack
Every obligation is discharged at the earliest tier that can hold it. The tiers are the language contract, referenced throughout this page:
| Tier | When | What happens |
|---|---|---|
| T0 | build | declaration validation, boundary-policy parse, full type check |
| T1 | module link | boundary-wrapper allocation, policy resolution, validator install |
| T2 | first use | lazy initialization (reserved; not currently used) |
| T3 | call | value validation and continue-mode dispatch |
Program structure
A .fts file is a sequence of top-level items. Ordinary script statements at top level are rejected: code lives in compartments.
SourceUnit := BoundaryDefault? Item*
Item := BoundaryPolicy | Compartment | TypeAlias
boundary default = <name>sets the policy an unqualifiedboundaryresolves to.boundary <name> = { … }declares a named policy (below).compartment <Name> boundary(…) { … }is the unit of code.type <Name> = <type>declares a type alias.
Comments use // (and /* */), as in TypeScript. A leading # is not a comment: inside a class it is the private-field sigil.
boundary default = secure
compartment Core boundary(secure) {
// a comment
export function greet(name: string): string {
return "hi " + name
}
}
type User = { name: string, age: number }
The boundary system
Every element below is documented exhaustively, with runtime-enforcement status, on the boundaries and compartments reference.
A boundary is the explicit contract governing every crossing between sound CruftScript and unsound JavaScript, or between compartments. Boundaries are declared on imports or inherited from the enclosing compartment, resolved at T1 (wrapper install), and enforced at T3 (value validation). The wrapper is a Cruft-specific intrinsic record (target, policy, validator), deliberately not a Proxy.
Policy declarations
A policy names what happens at each tier:
boundary secure = {
at process { install: wrapper, mode: strict }
at call {
on violation(expected: unknown, received: unknown) {
throw new CruftTypeError(...)
}
}
}
boundary debug = {
at process { install: wrapper, mode: debug }
at call {
on violation(expected: unknown, received: unknown) {
logBoundaryViolation({ expected, received })
}
}
}
The process mode is one of strict, debug, or sanitize.
Continue-modes
When validation fails at T3, the policy selects the continue-mode:
| Continue-mode | Policy | Behavior on violation |
|---|---|---|
| HALT | secure (default) | throw a TypeError; the stack unwinds. Argument/arity failures throw a plain unprefixed message; some paths carry a CruftBoundaryError: prefix (see Errors and diagnostics below — prefixing is not yet uniform) |
| PROPAGATE-AS-UNKNOWN | debug | the value crosses typed as unknown, a violation callback fires; the consumer must narrow before use |
| TRUST-WITH-OPT-OUT | export-only skip return validation | the value is returned untouched; the opt-out is recorded |
| SANITIZE | tolerant | replace the value with a declared per-type default from the policy's at sanitize table; record provenance, never pass the unsound value through |
HALT is enforced at the general function boundary today: a boundary(secure) export called from JS with a wrong-typed or wrong-arity argument HALTs at the crossing (see below). PROPAGATE-AS-UNKNOWN is live on the import/intrinsic tier; a plain boundary(debug) export argument still HALTs rather than propagating an unknown envelope at the export seam. SANITIZE ships today in the ORM's row boundary; the tolerant policy at the general function boundary is specified but not yet resolved, and boundary(tolerant) currently reports an unresolved-policy diagnostic.
Imports with boundaries
import { getUser } from "./legacy.js" boundary(secure)
import { fetchData } from "./api.js" boundary(debug)
import { saveUser } from "./legacy.js" boundary // shorthand: the default policy
boundary(skip return validation) on an import is a compile error. Skip-return is export-only (see below): the sound side may relax what it produces, never what unsound code returns.
Compartments
Compartments nest, and a nested compartment inherits the enclosing policy unless it overrides:
compartment LegacyCode boundary(secure) {
import { getUser } from "./legacy.js"
compartment LegacyAPI boundary(weaken to debug) {
import { fetchLegacyData } from "./legacy-api.js"
}
}
compartment CoreApp {
// inherits the enclosing default
import { processUser } from "./core.js"
}
Two ways to deviate from the inherited policy on a compartment:
boundary(override: debug)replaces the policy outright.boundary(weaken to debug)relaxes it as a marked escape hatch.
Cross-compartment calls need no special syntax: the boundary of the receiving compartment is applied automatically. A call must pass both the capability check (was the value endowed?) and the boundary check (do its types satisfy the contract?), in that order, with distinct diagnostics.
Function-level annotations
Boundary annotations on a function apply only at boundary call surfaces, exports called from outside the compartment and cross-compartment calls. They do not wrap ordinary same-compartment calls, which stay inside one soundness domain.
compartment LegacyCode boundary(secure) {
// full validation, inherited from the compartment
export function getUser(id: number): User { ... }
// opt out of return validation, allowed only on exports
export function getRawData(id: number): unknown boundary(skip return validation) { ... }
// combined with weakening
export function legacyFetch(): unknown boundary(weaken to debug, skip return validation) { ... }
}
Visible-when-deviating. Compartment-inherited boundaries are invisible in function signatures (the common case). A function-level override, weaken, or skip return validation is visible in the signature. Editor tooltips and error messages always show the resolved policy and its resolution chain, even when inherited.
Boundary-qualified function types
A function type can carry a boundary, so the contract travels in the type:
type GetUser = (id: number) => User boundary(secure)
An import's boundary becomes part of the imported identifier's type, and the checker propagates the obligation through call chains, resolving it at the call site. A function that itself declares a boundary, the point where unsound code enters, is the only place the annotation is visible.
The sanitize table
A tolerant policy declares inert per-type defaults at T0:
boundary tolerant = {
at process { install: wrapper, mode: sanitize }
at sanitize {
default string = ""
default number = 0
default boolean = false
default null = null
default Array<string> = []
default User = { name: "", role: "" }
}
at call {
on violation(expected: unknown, received: unknown) {
recordBoundarySanitization({ expected, received })
}
}
}
Each default T = expr binds one expected type to a default the checker proves has that type. Defaults must be inert data, not callables (no smuggled authority). A union needs an unambiguous default; any is forbidden and unknown may not be a sanitizer target. If a crossing has no matching default, the wrapper halts with a diagnostic naming the missing default.
Callback authority
A callback value that crosses a boundary may be invoked immediately and synchronously by the checked body, each argument validated as a boundary crossing. It must not be stored, returned as data, passed onward to imported JavaScript, installed in a listener registry, used as a sanitizer default, or invoked from a later turn. Callback authority is same-turn only; retained, cross-turn, and async callbacks are outside the language surface.
Type system
Every element below is documented exhaustively, with behavior, soundness deltas, and edge cases, on the type system reference.
The type system is TypeScript-shaped and, except for the runtime-narrowing operators, entirely a build-time (T0) construct that is erased before execution. Its governing rule is that no TypeScript unsoundness is admitted: every feature TypeScript admits unsoundly is either restricted to a sound subset or rejected. There is no any; unknown is the bottom type, and a value typed unknown must be narrowed before use.
Primitives and literals
string, number, boolean, bigint, symbol, null, undefined, void, never, and unknown. Literal types (string, number, boolean singletons) arrive through as const. Template-literal types are specified and consumed by mapped-type key remapping. any does not exist.
Object and structural types
Interfaces, type aliases, and object types with optional (?) and readonly members; tuples; arrays and readonly arrays and tuples; index signatures. as const narrows literals to singletons and marks the structure deeply readonly; it only ever restricts.
Unions, intersections, narrowing
Union (|) and intersection (&) types, including discriminated unions. Narrowing is sound and driven by the runtime-visible operators typeof, instanceof, and in, by user-defined type predicates (x is T), and by discriminant-field checks. These three operators are the type system's only runtime face.
Generics
Type parameters with extends constraints and defaults. Variance is explicit via in (contravariant) and out (covariant), the sound replacement for TypeScript's bivariant method parameters.
Conditional types
T extends U ? X : Y, distributive over naked type-parameter unions, with the non-distributive [T] extends [U] ? … opt-out, nesting, infer (including constrained infer U extends V), recursive conditionals, and NoInfer<T>.
Recursion must provably terminate. Each recursive instantiation must structurally decrease, the operand re-entered must be a strict sub-structure of the input. Where the checker cannot prove decrease it emits a build-time error. A depth bound (default 100) is a backstop that errors, never a silent truncation.
Mapped types
{ [K in keyof T]: … }, with +?/-? and +readonly/-readonly modifiers, key remapping (as), per-key conditional types, template-literal key generation, and key filtering via as never. keyof unknown is never.
Homomorphism is exact. A mapping is homomorphic only when its constraint is K in keyof T over a single parameter T; homomorphic mappings preserve T's per-property readonly and optional modifiers, non-homomorphic mappings inherit none. An explicit modifier always beats an inherited one: - removes, + (or bare) adds.
Utility types
The full set is provided as library aliases over the mapped/conditional/infer machinery: Partial, Required, Readonly, Record, Pick, Omit, Exclude, Extract, NonNullable, Parameters, ReturnType, ConstructorParameters, InstanceType, Awaited, ThisParameterType / OmitThisParameter, and NoInfer. Where TypeScript falls back to any (such as ReturnType of a non-function), CruftScript yields unknown or a hard error. NonNullable<T> is the sound type-level replacement for the ! operator. Each utility is documented in full, with behavior, soundness deltas, and edge cases, on the utility types reference.
keyof, typeof, indexed access
keyof T, the type query typeof x (build-time, distinct from the runtime typeof), and indexed access T[K], which distributes over a union K.
Inference and its failure
Downward (contextual) inference, return-type synthesis, parameter inference, generic-call inference, union and intersection candidate collection, variance-aware inference, NoInfer, and best-common-type resolution. When inference cannot resolve a type parameter it is a build-time error requiring an explicit type argument, never a silent widening to unknown or any. A function crossing a compartment boundary must state its parameter and return types explicitly; inference does not cross a boundary.
Casts and assertions
satisfieschecks assignability without widening the inferred type.asis narrow today: a cast fromunknownto a runtime-validator-backed concrete target (a primitive, or a checkable record shape) is admitted and lowered to a runtime assertion (RuntimeTypeAssertionFailedon a bad value). Same-type widening (n as (number | string)),as const, and anyunknown-bearing target are rejected withAssertionOperatorUnsupported.- The bare non-null
!is not a silent trust; the sound path is explicit narrowing orNonNullable<T>.
Decorators
The sound subset of the TC39 Stage 3 decorators: class, method, accessor, and field decorators, decorator factories, stacking, accessor auto-accessors, and soundly typed decorator metadata (Symbol.metadata, never any). A decorator runs with no ambient capability; capabilities are passed explicitly at the decoration site and validated at the boundary. Legacy parameter decorators and experimentalDecorators/reflect-metadata are not adopted. Decorators are typed at T0; the runtime support for executing them is not yet built.
Concurrency and capability types (specified, not yet enforced)
The spec defines a linear/affine-typing layer for the compartment-as-actor model: Transferable<T> (constructing it consumes the source binding; use-after-transfer is a build-time error), the opaque non-constructible Capability<K> and CapabilityBundle, DeepReadonly<T>, Shared<T> (deeply frozen and acyclic-by-construction; cyclic graphs are rejected), and Compartment<C, M> with a structural capability row. These are specified and not yet enforced by the shipped checker.
Standard library
A .fts body runs in the full realm, so the engine intrinsics are genuinely present, but the checker admits a curated, soundly typed subset: a method not in the signature table is refused at build time (PropertyReadUnsupported), never silently trusted. The admitted surface, by family:
| Family | Admitted surface |
|---|---|
| String | length, slice, substring, split, replace/replaceAll, match, search, trim/trimStart/trimEnd, toUpperCase/toLowerCase/toString, includes/startsWith/endsWith, indexOf/lastIndexOf, charAt/charCodeAt/codePointAt, at, repeat, padStart/padEnd, concat |
| Number | instance toFixed, toPrecision, toString; static Number.isInteger/isFinite/isNaN, Number.parseInt/parseFloat |
| Math | abs, floor, ceil, round, trunc, sign, sqrt, cbrt, max, min, pow, random |
| Array | length, push, pop/shift, at, slice, concat, reverse, join, indexOf/lastIndexOf, includes, and the same-turn callback methods map, filter, forEach, find, findIndex, some, every, reduce |
| Object | static Object.keys, Object.values |
| JSON | JSON.stringify (to string), JSON.parse (to unknown, narrow before use) |
| RegExp | test, exec, and source/flags/global reads; construct via new RegExp(...) or a /pat/flags literal |
Array.prototype.map refines its result element type from the callback's return. Callbacks follow the same-turn rule above. Notably absent (refused at build time): Array.prototype.sort and flat; Object.entries/assign/fromEntries; the method surfaces of Map, Set, Promise, Date, Symbol, the typed arrays, Proxy, and Reflect (their names resolve and they exist at runtime, but no typed methods are admitted yet); Intl and Temporal; and the bare global coercions parseInt/parseFloat/Number(x) (use the namespaced Number.* or Math.* instead). eval resolves as a name but is not admitted through the value path.
For the method-by-method signatures and worked examples, see the standard library page.
Errors and diagnostics
Build-time (T0) failures are checker diagnostics with a stage, a code, and a source span, for example:
$ cruft badbody.fts
cruft: fts diagnostic stage=check code=ReturnTypeMismatch span=124..132
message=return expression does not conform to declared return type
(exit 65)
Representative codes: ReturnTypeMismatch, LocalInitializerTypeMismatch, PropertyReadUnsupported, UnsupportedBodyExpression, UnsupportedFunctionShape, UnsupportedCompartmentShape, MalformedBoundaryDirective, SkipReturnValidationOnImport.
Runtime (T3) failures are boundary errors, all thrown as a TypeError. The message form varies by failure kind, and the CruftBoundaryError:-prefixed, chain-formatted shape below is aspirational — not what every path emits today:
CruftBoundaryError: Soundness violation at LegacyCode.secure
Boundary: LegacyCode.secure
Expected: string
Received: number
Call Site: CoreApp.processUser:12
Chain: secure ← compartment LegacyCode ← function getUser
In practice, argument and arity failures throw a plain, unprefixed TypeError — e.g. argument 0 for runtime export 'double' does not conform to parameter 'n' (RuntimeExportArgumentTypeMismatch) — with no CruftBoundaryError: prefix and no resolution chain. Some other T3 paths do carry a CruftBoundaryError: prefix (e.g. CruftBoundaryError: boundary validation failed at T3); the prefixing is not yet uniform.
On the secure policy every parameter is validated at the crossing: arity mismatches, wrong-typed primitives, and malformed record or array arguments each HALT rather than computing a wrong result. The capability check precedes the boundary check and raises its own distinct error.
Keywords
boundary, default, compartment, import, from, export, function, type, class, constructor, static, readonly, abstract, new, declare, namespace; the boundary clause words at, process, call, sanitize, on, violation, override, weaken, to, skip, return, validation, strict, debug; the type words keyof, typeof, infer, extends, in, out, as, const, satisfies, unique; and the type names number, string, boolean, bigint, symbol, null, undefined, unknown, never, void. any is reserved only to reject it.
What runs today
Every in-body element, and exactly which bucket it falls in, is documented on the statements and body language reference.
The surface splits three ways, and the split is worth stating plainly:
- Checked (broad). The whole type system, boundary-obligation propagation, narrowing, termination and homomorphism rules, and assertion soundness, is implemented in the checker and gates every
.ftsfile. This is the mature part. - Executed (narrow). A checked module that exports
mainruns when its body lowers to a constant result or to the admitted standard-library method calls. Boundary-wrapper install (T1) and call-time validation (T3) are live along this path, with the HALT and PROPAGATE-AS-UNKNOWN continue-modes. - Specified, not executed. A body outside the executable lowering is accepted by the checker but exits
70without running; thetolerant/SANITIZE continue-mode at the general function boundary, decorator execution, the affine concurrency types, and the multi-threaded compartment-as-actor model are specified but not yet shipped.
Substantive logic that does not fit the executable envelope, most string, regex, async, or IO-heavy code, lives in .ts and runs through erasure unchecked; the boundary is the seam that re-imposes checks when such a value is called through a typed .fts export.