This is my proposal for TypeScript-Ultra-Strict: a stricter TypeScript for a world where AI writes most of the code.
This repo is a thought experiment, not a production tool. The compiler works and every example passes, but there is no semantic type-checking, no exhaustiveness checking, no published packages, and no stability promise. It exists to make the proposal concrete enough to argue with. Do not build anything you care about on it yet.
Status: working MVP. packages/cli implements tsus check and tsus build, packages/runtime implements Option/Result/match and the boundary layer, packages/plugin provides webpack/Vite/esbuild/Bun adapters, and all 29 projects under examples/ pass (npm install && npm run examples). LEARNINGS.md documents what broke before the toolchain existed, how that shaped the design, and the known limitations.
TypeScript's early design decisions assumed humans would write and maintain it, so a lot of features exist to make a human's life easier. We valued speed over quality. With AI writing most of our code, we can flip that tradeoff and force the language to be declarative and verifiable, like Rust.
What changes
- Removes
nullandundefined. In their place,Option<T>andResult<T, E>become first-class types. This is the centerpiece: the removals below only work because these replacements exist. - Removes classes. Data is plain objects; behavior lives in functions.
- Removes
var. - Introduces an SDK for macros, used for pattern matching and variable destructuring over
Option,Result, and tagged unions. (TC39 already has a pattern-matching proposal in the pipeline; this accelerates where the language is heading.)
The interop boundary
Every existing DOM and npm API returns undefined, throws, and hands you classes. Ultra-Strict code never sees those directly. At the boundary, a checked bindings layer converts T | undefined and T | null into Option<T>, and throwing calls into Result<T, E>. Inside the boundary, the strict rules hold everywhere.
Why not just an eslint preset?
Banning classes, nulls, and var can be approximated with tsconfig and lint rules today. Two things need a real compiler:
- The macro SDK. This is the honest cost of the proposal: TypeScript's types are fully erasable, which is why esbuild and swc can strip them in a single fast pass. Macros break that property. The bet is that with AI writing the code, we should spend build-time budget on expressiveness and checkability rather than on keeping type-stripping trivial.
- The boundary layer, which needs type information to generate the
Option/Resultwrappers.
Why TypeScript?
Back in 2008, Google shipped Chrome and V8, and the industry built its edge compute on top of that runtime. Cloudflare Workers runs V8 isolates directly. Node, Deno, and Bun's ecosystem all trace back to it. JavaScript and ESM are the substrate that's already deployed everywhere.
TypeScript is the typed layer the ecosystem already adopted. Rather than asking everyone to move to a new language, Ultra-Strict is a subset-plus-macros of a language AI models already know deeply, targeting a runtime that's already everywhere.
Ideal UX
Any file with a .tsus extension (TypeScript Ultra Strict) is processed under the strict rules and compiled to plain JavaScript. .ts and .tsus files coexist in one project, so adoption is per-file: an AI agent can write new modules in .tsus while the legacy .ts code keeps working untouched. Importing a .tsus module from .ts just works; importing .ts (or any npm package) into .tsus goes through the boundary layer, which rewrites the types so nullable returns arrive as Option<T> and throwing functions arrive as Result<T, E>.
What a .tsus file looks like:
import { readFile } from "node:fs/promises"; // boundary-wrapped: returns Result type User = { name: string; email: Option<string> }; const loadUser = async (path: string): Promise<Result<User, IoError>> => { const raw = await readFile(path, "utf8"); return raw.map(parseUser); }; // macro from the SDK const greeting = match(user.email) { Some(email) => `Reach me at ${email}`, None => "No email on file", };
Framework integrations
packages/plugin (@tsus/plugin) is one transform core with a thin adapter per host, all sharing the CLI's preprocessor and checker:
@tsus/plugin/webpack: a loader. In Next.js, one rule innext.config.mjsmakes.tsusimportable from any page or component (examples/27-nextjs, verified bynext buildemitting the rendered string into static HTML).@tsus/plugin/vite: a Vite plugin, which also covers Astro, SvelteKit, and Nuxt through theirvite.pluginsconfig (examples/28-astro).@tsus/plugin/esbuild: an esbuild plugin. Bun's plugin API is esbuild-shaped, so the same object registers withBun.plugin; with a one-linebunfig.tomlpreload,bun runexecutes.tsusimports natively with no build step (examples/29-bun).
The adapters deliberately leave import specifiers untouched: the host bundler resolves .tsus to .tsus chains through its own pipeline and each hop hits the transform again. The tsus CLI stays the standalone path (and the CI path, via tsus check --json).
Tooling:
tsus buildcompiles to JS,tsus checktype-checks without emitting. Both run as an esbuild/swc plugin so existing bundler setups pick up.tsusfiles without config changes.- Editor support ships as a TypeScript language service plugin, so VS Code and every TS-aware editor get diagnostics, go-to-definition, and macro expansion previews for free.
- Diagnostics are machine-first: every error has a stable code, a JSON output mode, and a suggested fix, so an agent can consume
tsus check --jsonin a loop without parsing prose. Human-readable output is a rendering of the same data. - No new package manager and no new registry. npm packages work through the boundary layer, and published
.tsuspackages ship compiled JS plus.d.tsfiles, so consumers don't need to know the source language existed.
The examples
examples/ is the test suite: 29 small projects, each with an example.json declaring what must happen. npm run examples builds and executes all of them and fails on any drift.
Core language and runtime
| Example | Shows |
|---|---|
01-hello |
Smallest possible .tsus file compiling to plain ESM |
02-option-basics |
Option<T> with map/andThen/unwrapOr |
03-result-basics |
Result<T, E> with map/mapErr |
04-match-option, 05-match-result |
The match macro over Some/None and Ok/Err |
06-tagged-union |
User-defined tagged unions with a _ wildcard arm |
Rejected on purpose (these pass by failing the checker with the right code)
| Example | Code |
|---|---|
07-banned-null |
TSUS002 |
08-banned-class |
TSUS001 |
09-banned-var |
TSUS004 |
10-banned-undefined |
TSUS003 |
11-banned-throw |
TSUS005 |
Interop and stress
| Example | Shows |
|---|---|
12-tsus-imports-tsus, 20-deep-chain |
.tsus import chains, four levels deep |
13-tsus-imports-ts, 14-ts-imports-tsus |
Mixed projects in both directions; .ts keeps its classes and nulls |
15-npm-dependency |
zod feeding Result |
16-boundary-fs, 26-json-boundary |
wrapAsync/wrapSync turning throws into Result |
17-nested-match, 18-generics, 19-async-result |
Nested matches, generic signatures, await inside match arms |
21-circular-imports |
Function-level .tsus cycles (fine, because ESM) |
22-mixed-project |
ts + tsus + zod + boundary in one project |
24-hostile-syntax |
Decoy match blocks in strings, comments, and template literals |
25-shadowed-match |
A user function named match staying a plain call |
Runtimes and frameworks
| Example | Shows |
|---|---|
23-cloudflare-worker |
Cloudflare { fetch } module shape, tested against real Request/Response |
27-nextjs |
Next.js 14 via the webpack loader; next build renders .tsus output into static HTML |
28-astro |
Astro 4 via the Vite plugin, verified in built HTML |
29-bun |
Bun runs .tsus imports natively via a bunfig.toml preload, no build step |