Most people learn TypeScript as a labeling system: you have some data, you write a type that describes its shape, the editor gives you autocomplete. That's useful, but it's the small half of what the type system is for. The bigger half is using types to make certain wrong states impossible to write down — so the bug you'd otherwise catch in testing becomes a red squiggle you can't ship past.
Once that clicks, you stop asking "what does this data look like?" and start asking "what should this code never allow?" Here are the three moves that get you there.
Boolean soup: when every flag doubles your bad states
Here's a type I've written & reviewed, more times than I'd like:
type RequestState = {
loading: boolean;
data?: User;
error?: string;
};Looks reasonable. It also describes eight possible combinations & most of them are nonsense. loading: true and data present? Loading finished but there's an error and data? What does loading: false with neither data nor error mean — did it fail, or has it just not started? The type happily allows all of these, so somewhere in your component you end up writing defensive checks for states that should never have existed in the first place.
The problem is that these fields aren't independent. A request is in exactly one situation at a time. So say that with a discriminated union:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; message: string };Now the impossible states are genuinely gone. There's no way to have data while loading, because the loading variant doesn't have a data field. And the payoff shows up when you read it back — TypeScript narrows the type based on status:
function render(state: RequestState) {
switch (state.status) {
case "loading":
return "Loading…";
case "error":
return state.message; // data isn't even in scope here
case "success":
return state.data.name; // and here, data is guaranteed to exist
}
}Inside the success branch, state.data is not User | undefined — it's User, no optional chaining, no if (data) guard. The type system did the guarding for you, because you told it these fields travel together.
Independent booleans multiply your states — three flags is eight combinations, most of them nonsense. A discriminated union says "it's in exactly one of these," and the impossible states stop existing.
satisfies: check the shape without flattening the values
This one solves a specific annoyance. You have a config object and you want two things at once: to check it matches an expected shape, and to keep the exact literal types of the values. For a long time you could only get one.
// Annotate with a type & you lose the specifics.
const routes: Record<string, string> = {
home: "/",
profile: "/profile",
};
routes.home; // type is string — the fact that it's "/" is gone
routes.dashbord; // typo — but no error, any string key is allowedThe annotation checks the shape but throws away the detail. satisfies checks the shape and keeps the detail:
const routes = {
home: "/",
profile: "/profile",
} satisfies Record<string, string>;
routes.home; // type is "/", the literal
routes.dashbord; // error: property doesn't exist — typo caughtYou get validated against Record<string, string> — every value must be a string — but the inferred type stays narrow, so routes.home is "/" and mistyped keys are errors instead of silently-allowed strings. The rule of thumb: use a type annotation (: T) when you want the variable to be widened to T & satisfies T when you want it checked against T but keep the specifics.
Stop typing your data twice — derive it
The habit that quietly doubles your maintenance is writing a value and then writing a separate type for that same value by hand. Now they can drift & one day they will.
const ROLES = ["admin", "editor", "viewer"] as const;
// Don't hand-write this next to the array — derive it.
type Role = (typeof ROLES)[number]; // "admin" | "editor" | "viewer"as const freezes the array into a readonly tuple of literals & (typeof ROLES)[number] reads the union of its elements straight back out. Add a role to the array and the type updates itself — there's no second list to remember. The same idea works across your codebase: keyof typeof someObject for its keys, indexed access like Config["timeout"] to pull one field's type out of a bigger one. The principle is that there should be one source of truth & the types should be computed from it, not maintained alongside it.
A good smell test
If you change a value and have to change a type in a second place to match,
you're maintaining the same fact twice. That's usually a sign you should be
deriving the type from the value with typeof / keyof instead of writing
it out by hand.
The mistake underneath all of this
The common thread in the bad versions above is treating TypeScript as documentation you write after deciding on the data. You shape the data however, then bolt on a type that describes whatever you made — including all its invalid combinations.
The shift is to let the type come first and be restrictive. A good type doesn't just describe what your data can be; it rules out what it should never be. When you model states as "exactly one of these" instead of "a bag of optional fields," when you let satisfies keep your literals sharp & when you derive types from values instead of copying them, whole categories of bug stop being things you test for and become things the compiler won't let you write.
That's the real value on offer. Not autocomplete — though you'll get that too. It's that the wrong program stops compiling. Aim your types at forbidding bad states & TypeScript starts earning its keep.