GitHub - chitalian/Typescript-Ultra-Strict

6 min read Original article ↗

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

  1. Removes null and undefined. In their place, Option<T> and Result<T, E> become first-class types. This is the centerpiece: the removals below only work because these replacements exist.
  2. Removes classes. Data is plain objects; behavior lives in functions.
  3. Removes var.
  4. 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:

  1. 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.
  2. The boundary layer, which needs type information to generate the Option/Result wrappers.

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 in next.config.mjs makes .tsus importable from any page or component (examples/27-nextjs, verified by next build emitting the rendered string into static HTML).
  • @tsus/plugin/vite: a Vite plugin, which also covers Astro, SvelteKit, and Nuxt through their vite.plugins config (examples/28-astro).
  • @tsus/plugin/esbuild: an esbuild plugin. Bun's plugin API is esbuild-shaped, so the same object registers with Bun.plugin; with a one-line bunfig.toml preload, bun run executes .tsus imports 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 build compiles to JS, tsus check type-checks without emitting. Both run as an esbuild/swc plugin so existing bundler setups pick up .tsus files 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 --json in 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 .tsus packages ship compiled JS plus .d.ts files, 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