Plenty of teams use TypeScript and still ship the same class of bugs they did in JavaScript — because they treat types as paperwork to satisfy after the logic is written. Used well, the type system is a design tool: you model your domain so that incorrect states are not just discouraged but unrepresentable, and the compiler catches mistakes before they reach a test.
Loose modelling
Optional fields · Impossible combinations still compile · Bugs slip through
Discriminated union
One field per state · Only real states can exist · Compiler narrows for you
Make illegal states unrepresentable
The most valuable habit in TypeScript is shaping types so impossible combinations cannot be expressed. If a request is either loading, loaded with data, or failed with an error, do not model it as three optional fields you have to keep in sync — model it as one of three exact shapes.
// loose: every field optional, all combinations "valid"
type Bad = { loading?: boolean; data?: User; error?: string };
// tight: only the three real states can exist
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Discriminated unions are your workhorse
That pattern above is a discriminated union, and it is the single most useful tool in everyday TypeScript. A shared literal field — status here — lets the compiler narrow the type inside each branch, so accessing data is only allowed once you have checked status === "success". The result is exhaustive, self-documenting logic.
If you find yourself writing "this field exists, but only when that other field is set", you want a union.
Generics with restraint
Generics are powerful and easy to overdo. A good generic reads almost like plain English and earns its abstraction; a bad one turns a simple function into a puzzle of nested type parameters. Reach for a generic when a function genuinely works over many types in the same way — and stop the moment the signature becomes harder to read than the body.
Let inference do the work
You do not need to annotate everything. TypeScript's inference is strong — over-annotating adds noise and can actually hide better types the compiler would have derived. Annotate the boundaries (function parameters, public APIs, return types you want to lock) and let inference handle the interior.
- Annotate inputs and public surfaces; infer the rest.
- Prefer unions over optional-field soup for anything stateful.
- Turn on
strict— every flag it enables prevents a real bug.
Great TypeScript is quiet. It does not show off; it just makes the wrong thing fail to compile, so the bug you would have shipped becomes a red squiggle you fix in seconds.