TypeScript support

Cruft runs .ts, .mts, and .cts files directly by erasing their type annotations, with no tsc and no build step. Erased spans become spaces so runtime errors still point at your original source. Constructs that need generated runtime code, such as enum, are refused rather than silently transformed.

Cruft runs .ts, .mts, and .cts files directly, no tsc, no build step, no configuration. This page covers the model precisely: what erasure is, what is erased vs. refused, how errors keep pointing at your real source, and where the boundaries and known defects sit. It builds on the loading stage in How your code runs.

The model: erase or refuse

Cruft's TypeScript support is type erasure for ecosystem fidelity, with Node's native --strip-types behavior as the reference. It deliberately does not follow tsc. Three commitments define it:

  1. No type checking. Types are erased, never verified. A file that would fail tsc still runs as long as it parses; correctness checking belongs to your editor and CI, not the runtime. This is the same division Node chose, and it is what makes the feature zero-config.
  2. Erasure is pure deletion. Every supported construct strips to nothing (spaces, actually, see below). If removing the syntax would change runtime behavior, it isn't a candidate for erasure.
  3. Refuse cleanly. Constructs whose semantics require generating runtime code are rejected with a diagnostic that says exactly why. The invariant: the eraser emits valid JavaScript or refuses, there is no third outcome where it emits something subtly wrong.

What is erased

The erasable subset is the entire type-level language:

  • : T annotations on variables, parameters, fields, and returns
  • ? optional and ! non-null postfix markers
  • as T casts and satisfies T clauses
  • generic type parameters and call-site type arguments
  • interface and type declarations (leave nothing behind)
  • declare statements
  • abstract, readonly, and access modifiers on class members
  • import type / export type (fully erased; value imports from the same module still resolve)

Modern class syntax that is real JavaScript, private fields, static blocks, accessors, is untouched by the eraser and simply runs.

What is refused

Constructs that are values in disguise, TypeScript syntax whose meaning is runtime code the eraser would have to invent:

  • enum: a runtime object with reverse mappings. Refused at strip time with a diagnostic naming the reason ("a runtime enum requires lowering to a runtime object, which the type-eraser does not emit").
  • value namespace: a runtime IIFE-and-object pattern.
  • constructor parameter properties (constructor(public x)), implicit field declarations plus assignments.

The refusals are deliberate: each of these has a mechanical JavaScript spelling (const Color = {...}, a plain object, an explicit field + assignment), modern TypeScript style guidance already trends away from them, and Node's strip-types mode draws the identical line. A runtime that silently lowered them would be quietly running code you never wrote.

Space-preserving erasure: errors point at your source

Erased spans are replaced with spaces, so every surviving token keeps its original byte position. The consequence is that runtime error locations, stack traces, and spans point directly into the .ts file you edited, with no source maps, no offset translation, no generated intermediate file to debug through. The absence of machinery is the feature: there is nothing to misconfigure and nothing to drift.

This is also why "erase, never transform" matters so much: the moment a construct is rewritten rather than deleted, positions shift and the zero-source-map property dies. The refuse list above is the price of the error-fidelity guarantee, and it is paid deliberately.

Module semantics

TypeScript files participate in module classification like their JavaScript counterparts: .mts is ESM, .cts is CJS, .ts follows the nearest package.json "type" marker. Imports may name .ts source paths directly. After the strip, the file is JavaScript to every downstream system, the module graph, Exegesis, the JIT have no TypeScript-awareness at all.

One sharp edge by design: -e does not strip. Inline --eval source is plain JavaScript; type annotations there are a parse error. TypeScript lives in files with TypeScript extensions, where the extension keys the dispatch.

Testing against Node's stripper

A cruft-vs-Node differential harness runs TypeScript corpora under both runtimes and compares outcomes, with Node's strip-types behavior as the parity floor. Divergences are triaged by kind: cases that should erase but don't yet, cases that should refuse more cleanly, and diagnostics-quality items.

Two silent stripper defects were found and fixed this way: aliased named imports mis-stripping, and a union-literal function-type annotation corrupting its statement. An aliased-import file like import { readFileSync as rf } from "node:fs" alongside erased interfaces, type aliases, generics, and as casts strips cleanly and runs.

Boundaries with neighboring systems

  • Not tsc parity. Where tsc's emit and Node's strip-types disagree, Cruft follows Node. Anything requiring the type checker (path mapping via tsconfig, const enum inlining, decorator metadata) is out of scope by construction.
  • tsconfig.json is not consulted. Resolution and behavior are convention-driven; an opt-in stricter TS resolution mode is modeled as a named compatibility exception, not silently read from config.
  • Orthogonal to CruftScript. Cruft also carries CruftScript, a separate sound typed language (.fts) with its own checker and runtime boundary validation. The two share nothing conceptually: TypeScript support is unsound erasure for running the existing ecosystem; CruftScript is a different language for writing verified new code. Conflating them, for example, expecting Cruft to type-check .ts, misreads both.