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.
// 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.
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 hole | CruftScript | TypeScript |
|---|---|---|
any | not spellable | turns off checking |
as unknown as T | compile error | silent override |
non-null x! | inserts a runtime check | erased, unchecked |
| unverified external data | must be narrowed | often 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.