CruftScript™

The sound typed language for the JS world.

CruftScript is the first sound typed language built for the JavaScript world. It reads like TypeScript, runs on Cruft, and works alongside your existing JavaScript and TypeScript. The difference: a value typed User has actually been checked to be one.

Read the reference

users.fts
// greet only accepts a real User. When untyped JavaScript calls
// in, the boundary validates the argument before greet runs.
boundary default = secure
type User = { id: number, name: string }

compartment Core boundary(secure) {
  export function greet(u: User): string boundary(secure) {
    return "hi " + u.name;
  }
}
// from untyped JS: import { greet } from "./users.fts"
greet({ id: 7, name: "Ada" }) → hi Ada
greet({ id: 7 }) → TypeError: argument 0 does not conform to parameter `u`

An agent hands you JSON. Can you trust it?

A model returns a tool call. TypeScript takes your word that it is a ToolCall. CruftScript checks it at the boundary, before your tools run. Every snippet below runs on cruft 0.0.10.

1The untrusted input

An agent returns a tool call as plain JSON. It might be malformed, hallucinated, or missing a field, here args is gone.

// the model's output, shape unproven
const output = { name: "read_file" };   // no args
2What TypeScript does

The as is a promise the compiler cannot keep. The missing args sails straight through; the type says ToolCall, the value is not one, and it blows up later, far from here.

const call = output as ToolCall;

call.args.path;   // undefined at runtime; TS said it was safe
3What CruftScript does

The tool runner is a CruftScript compartment that exports run(call: ToolCall). When your agent code calls it, the argument is validated at the boundary, and a malformed call is rejected right there, before the tool executes.

// tool.fts, the sound tool runner
compartment Core boundary(secure) {
  export function run(call: ToolCall): string boundary(secure) {
    return call.name
  }
}
// your agent calls in with the model's output
import { run } from "./tool.fts";
run(output);
TypeError: argument 0 for runtime export `run` does not conform to parameter `call`
4What you get

Inside run, call is a real ToolCall. No defensive re-parsing, no if (call.args) guards, no hallucinated arguments reaching your tools. The one place bad data could enter is the one place it is checked.

export function run(call: ToolCall): string boundary(secure) {
  // call.args is guaranteed here; act on it directly
  return tools[call.name](call.args)
}

No escape hatch

There is no any

TypeScript's any is a value the checker stops reasoning about, and it is how most unsound code slips in. CruftScript has no such keyword. unknown is the only catch-all, and it must be checked with a real runtime test before you can use it. A cast cannot skip the check, and the as unknown as T double-cast is a compile error.

const body: unknown = await req.json();

body.id;                       // error: unknown has no shape yet

if (typeof body === "object" && body !== null
    && "id" in body) {
  body.id;                     // checked, safe to use
}

Runnable on cruft 0.0.10

Not a mockup. Run it yourself.

A complete CruftScript program you can paste into a file and run right now. Save it as soundcheck.fts, run cruft soundcheck.fts, and follow each step, every command below is real output from cruft 0.0.10.

1The whole program

One file, one compartment, one export named main. There is no framework and no build step: the runtime type-checks the file, then invokes main and prints what it returns. The annotation on user is the claim the checker will hold you to.

soundcheck.fts
boundary default = secure

compartment Core boundary(secure) {
  export function main(): string boundary(secure) {
    const user: { id: number, name: string } = { id: 7, name: "Ada" }
    return user.name
  }
}
2Run it

The checker proves every annotation first, then the runtime calls main and prints its return value. Nothing to wire up, the return of main is the program's output.

$ cruft soundcheck.fts
Ada
3Reach for the escape hatch

Change the one annotation to any, the keyword every other typed language gives you to turn checking off. CruftScript does not have it. The refusal happens at stage=check, before a single line of the program runs.

    const user: any = { id: 7, name: "Ada" }
$ cruft soundcheck.fts
error [AnyTypeRejected]: `any` is not a CruftScript type;
use `unknown` and narrow it
4Every annotation is load-bearing

It is not only any. Put the wrong value behind a right-looking annotation, return user.id, a number, where the signature promised a string, and the same gate stops it. The checker will not let the program run until every claim it makes is proven, so a type error can never reach production as a runtime surprise.

    return user.id   // declared return is string
$ cruft soundcheck.fts
error [ReturnTypeMismatch]: return expression does not
conform to declared return type
// a boundary names where untrusted data enters,
// and what to do when it does not match
import { rows } from "./db.js" boundary(secure);

// each row is validated against Order as it crosses;
// a bad row is caught here, not three functions later
const orders: Order[] = rows("select * from orders");

Interop with the whole ecosystem

The boundary decides trust

CruftScript does not ask you to rewrite your world. Import ordinary JavaScript and TypeScript, and mark the edge where their values enter with a boundary. Everything crossing that edge is validated against the type you declared. Your typed code stays sound because the one place unverified data could reach it is the one place it gets checked.

When it cannot decide, it stops

A full type system, kept honest

Generics, unions, conditional and mapped types, infer, recursion: the tools you expect are here. What is different is the discipline. A recursive type must be shown to finish, inference that cannot resolve a parameter is an error rather than a silent widen, and a type computation with no provable answer is reported instead of guessed. The type system never quietly hands you a wrong answer.

// resolves by proof, not by a depth limiter
type Flatten<T> = T extends Array<infer E>
  ? Flatten<E> : T;

// undecidable computations are refused, not widened
type K = keyof unknown;   // never (no keys), never `any`

The unsoundness other typed languages ship

The holes, closed

Sound typing means the type checker's promises hold at run time. These are the well-known places TypeScript lets a value be something other than its type, and what CruftScript does at each.

The holeCruftScriptTypeScript
anynot spellableturns off checking
as unknown as Tcompile errorsilent override
non-null x!inserts a runtime checkerased, unchecked
unverified external datamust be narrowedoften typed by assertion

Each closed hole has a sound alternative that reads almost the same, so the cost is a little more honesty at the edges, not a different way of writing code.

# a .fts file runs on the cruft runtime
$ cruft run users.fts

# or compile alongside your TypeScript,
# checked the same way your .ts is
$ cruft build src/

Runs on Cruft

Part of the stack, not a bolt-on

CruftScript is checked and run by the same runtime as your JavaScript and TypeScript. A directly-executable subset runs today as .fts; the broader language is checked and compiled through the ordinary path, so its annotations work across a whole project. The type checker is live and broad now, and each page in the docs is plain about what runs today and what is still being built.

Read the language

Start with why it is built this way, then the type system one construct at a time, the full language reference, and the standard library a .fts body may call.