Rust-inspired TypeScript: 4 habits that kill whole classes of bugs
I've reviewed a lot of TypeScript. Most of the production bugs I've signed off on, then regretted, had the same boring shape: the code got into a state nobody planned for. A spinner and an error showing at the same time. A receipt with no cart behind it. A "product ID" that was just any old string the back end handed us.
I caught a talk by Rijk van Zanten of Directus, "Rust-inspired TypeScript," that put a clean name on the fix. He's written over a quarter million lines of TypeScript by hand and spent the last year building a product in Rust. His point isn't that Rust is better. It's that Rust is strict about things TypeScript lets you ignore, and you can steal that strictness without changing your runtime.
Here's the promise: four habits you can adopt one file at a time. No rewrite, no big refactor. Each one removes a category of bug instead of patching a single one. (Watch the original talk here.)
1. Make impossible states impossible
Look at any type with a few booleans and a handful of optional fields. That's usually a state machine wearing a disguise.
A checkout type like this feels flexible:
type Checkout = {
loading: boolean;
error?: string;
cart?: Cart;
receipt?: Receipt;
paid: boolean;
};
It allows every state you want. It also allows a pile you don't: loading and paid at once, a receipt with no cart, an error sitting next to a successful payment. Every one of those is a bug waiting for a Tuesday afternoon.
Rust handles this with enums where each variant carries its own data, and a value is only ever one variant at a time. TypeScript doesn't have that kind of enum, but it has discriminated unions, which do the same job:
type Checkout =
| { status: "empty" }
| { status: "filled"; cart: Cart }
| { status: "ready"; cart: Cart; total: number }
| { status: "paid"; cart: Cart; receipt: Receipt }
| { status: "failed"; cart: Cart; error: string };
Now the type documents reality. If it's empty, there's no cart. If it failed, there's an error. If it's paid, there's a receipt. You cannot construct a value that's both paid and failed, because the shape won't let you.
The second half is making sure you actually handle every case. Use the never type as a tripwire:
function render(checkout: Checkout): string {
switch (checkout.status) {
case "empty": return "Your cart is empty";
case "filled": return "Items in cart";
case "ready": return "Ready to pay";
case "paid": return "Thanks for your order";
case "failed": return checkout.error;
default:
const _exhaustive: never = checkout;
return _exhaustive;
}
}
Add a new status to the union and forget to handle it? The default branch becomes a compile error, not a 2am production incident. If the type check passes, every state is handled. That alone shortens code review, because you no longer have to eyeball whether someone covered all the cases.
This idea connects to something I wrote in What is technical debt: the cheapest debt to pay off is the kind the compiler collects for you.
2. Make expected failures visible
Most TypeScript functions are written for the happy path. The signature says "give me a customer, get back a receipt." Reality says the card might be missing, the provider might decline, the provider might be down entirely. None of that shows up in the type.
In Rust, recoverable errors are part of the return type, not a hidden exception that floats up until something catches it. You can do the same in TypeScript with a result type:
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
type PaymentError =
| { kind: "no_card"; customerId: string }
| { kind: "declined"; reason: string }
| { kind: "provider_down" };
function charge(customer: Customer, amount: number): Result<Receipt, PaymentError> {
// ...returns { ok: true, value } or { ok: false, error }
}
Two things change. The caller can see, from the signature alone, that this can fail and how. And the errors carry their own context: no_card knows which customer, declined has a reason string, and you can never have both at once.
The questions that normally come up after a bug ("can this fail? where's that handled? is there a try/catch upstream or some middleware waiting?") are answered by the type before anyone writes the calling code.
To be clear, exceptions still have a place. Programmer errors and broken invariants, the stuff a caller can't sensibly recover from, those can still throw. But for the failures you expect a normal caller to handle, a return type beats a thrown error. If you want the ergonomics without hand-rolling everything, libraries like Better Result and Effect implement this pattern with Rust-style helpers.
3. Validate at the boundary
The first two habits work because you control the types. That breaks the moment data comes from outside: an API response, a form, an SDK. This is where TypeScript gives you the most false confidence.
You've probably written this. I have, then blamed the back-end team when it broke:
const data = await res.json();
const card = data as CardReadResponse; // trust me, bro
as validates nothing. It tells the compiler to take an unknown value at face value. Two problems hide here: the shape was never checked, and a "product ID" passed around as a plain string is just a string, with no guarantee it's a real ID.
Rust's habit is to wrap raw values in domain types. A ProductId holds a string, but not every string is a ProductId. You can copy that in TypeScript with a class and a private constructor, so the only way in is through validation:
class ProductId {
private constructor(public readonly value: string) {}
static tryFrom(raw: string): Result<ProductId, string> {
if (!raw.startsWith("PRD")) {
return { ok: false, error: "Not a product ID" };
}
return { ok: true, value: new ProductId(raw) };
}
}
You can't do new ProductId("anything"). You have to go through tryFrom, which hands back a result you handle like any other error. Now getProduct(id: ProductId) is genuinely hard to misuse.
For the API response, use a schema validator. I still reach for Zod, mostly because it can parse without throwing:
import { z } from "zod";
const CardReadResponse = z.object({
id: z.string(),
last4: z.string(),
brand: z.string(),
});
const parsed = CardReadResponse.safeParse(await res.json());
if (!parsed.success) {
// handle it: in an API, return a 422 here
}
safeParse returns the same shape as our result type, so it slots right in. One note on future-proofing: let the schema ignore properties you don't recognize, so a backwards-compatible API change (one new optional field) doesn't blow up your parsing. The front end doesn't need it, so don't fail on it.
That's the whole "parse, don't validate" idea. Take untrusted data in as unknown, parse it once at the edge, and from there the type is trustworthy. If you care about boundary safety, it pairs well with getting auth right, which I covered in why your auth system is probably wrong.
4. Keep mutations local
This is where Rust and TypeScript feel furthest apart. In JavaScript almost everything is mutable by default, and you can't tell from a signature what a function is allowed to change. Does addItem mutate the cart or return a new one? Does applyCoupon recalculate totals in place? const helped, but a const array or object is still mutable. So there's no real source of truth.
Rust makes the difference loud. A function takes either a read-only borrow or a &mut borrow, and that one keyword tells the caller, the compiler, and the reviewer that this function will change the value.
TypeScript has no mut keyword, but readonly types get you most of the way:
type Cart = {
readonly items: readonly Item[];
readonly total: number;
};
type MutableCart = {
items: Item[];
total: number;
};
function priceOf(cart: Cart): number { /* can't mutate */ }
function addItem(cart: MutableCart, item: Item): void { /* allowed to */ }
A MutableCart is usable where a Cart is expected, but not the other way around. A reviewer reads the signature and knows instantly which functions touch state. You're making the unsafe thing the one you have to ask for, instead of the default.
The honorable mention: boring tooling
One aside from the talk that's worth repeating. The JS ecosystem lets you compose your own stack, which is great for moving fast and miserable for onboarding. Every repo has different commands. Rust has Cargo, one tool everyone uses, so the commands are the same everywhere. The web is catching up: VoidZero's unified toolchain (VPlus) aims for that one-tool feel, and it's built out of Rust tools, which is a fun loop. If you've felt the pain of runtime tooling sprawl, I get into a related angle in runtime UI libraries are tech debt you can't see.
Why this matters more now
Here's the part I'd underline. We're shipping more code than ever, and a lot of it is AI-generated. Loose TypeScript makes it trivially easy for an agent to sneak in an assumption or an unhandled state that a human reviewer skims right past, and that an agentic reviewer misses too. Strict types are a second reviewer that never gets tired. They give both you and the agent something concrete to check against. If you're leaning on AI to write code, this is exactly the kind of guardrail I argued for in the vibe coding lie.
Yes, there's a cost. More boilerplate, more types to think through up front. But ambiguity has a cost too, it just arrives later, which makes it easy to ignore on day one. Pay a little up front. You won't regret it.
What to do now
Pick one file. Not the whole codebase, one file. Find a type with a couple of booleans and some optional fields, and turn it into a discriminated union with a never check. Ship it. See how it feels in review.
Then do the next one. These habits compound: stricter state, visible failures, validated boundaries, local mutations. None of them needs a rewrite, and each removes a class of bug instead of one bug at a time. The best place to steal ideas is over the fence, from another language or framework. Go look.
If you want the building blocks these habits sit on, my API design guide and the rundown in ES2025: what's new in JavaScript are good next reads.