I've onboarded a few teams onto TypeScript, and the same pattern shows up every time: people try to use everything at once. Generics everywhere, conditional types, mapped types, decorators. Then they get frustrated and blame TypeScript.
The trick is to adopt features in the order that pays off fastest. Here's the order I'd recommend, based on what actually removed bugs from my code.
1. strict mode in tsconfig
This isn't a feature so much as a setting, but it's the highest-value thing you can do on day one. Turn it on before you write a lot of code, because retrofitting it later is painful.
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true
}
}
strict bundles strictNullChecks, noImplicitAny, and friends. noUncheckedIndexedAccess is separate and not included in strict, but it's worth adding: it makes arr[0] return T | undefined instead of T, which catches a whole class of off-by-one crashes.
If you're migrating an existing JS codebase, you can turn these on per-directory instead of globally.
2. Union types and narrowing
This is the feature that made me stop missing dynamic languages. A union type plus a discriminant is often all you need instead of a class hierarchy.
type Result =
| { status: "ok"; data: string }
| { status: "error"; message: string };
function handle(r: Result) {
if (r.status === "ok") {
return r.data; // TS knows data exists here
}
return r.message; // and message here
}
No casts, no any. The switch on r.status narrows the type automatically. I use this shape for API responses, form state, and reducers constantly.
3. unknown instead of any
any turns off type checking for everything it touches. unknown is the safe version: you can assign anything to it, but you have to narrow it before using it.
function parseJSON(input: string): unknown {
return JSON.parse(input);
}
const data = parseJSON(raw);
// data.foo // error: object is of type 'unknown'
if (typeof data === "object" && data !== null && "id" in data) {
console.log(data.id);
}
Use unknown at every boundary where data comes from outside your program: JSON.parse, fetch, localStorage, message handlers. Then write a small type guard to narrow it.
4. Type guards and satisfies
A user-defined type guard is just a function with a value is Type return annotation.
function isUser(v: unknown): v is { id: number; name: string } {
return (
typeof v === "object" &&
v !== null &&
"id" in v &&
typeof (v as any).id === "number"
);
}
Once you have guards, satisfies (added in TS 4.9) becomes useful. It checks a value against a type without widening it:
const routes = {
home: "/",
about: "/about",
} satisfies Record<string, `/${string}`>;
// routes.home is still typed as "/", not string
I reach for satisfies on config objects where I want both validation and literal types preserved.
5. Utility types you'll actually use
You don't need to learn all of them. Start with four:
-
Partial<T>for update payloads -
Pick<T, K>andOmit<T, K>for DTOs derived from a domain model -
Record<K, V>for lookup maps -
ReturnType<typeof fn>for inferring types from existing functions
interface User { id: number; name: string; email: string }
type UserUpdate = Partial<Pick<User, "name" | "email">>;
type PublicUser = Omit<User, "email">;
Deriving types from a single source of truth beats hand-writing parallel interfaces that drift apart.
What to skip for now
Conditional types, template literal type gymnastics, and heavy generic abstractions are powerful but rarely worth the readability cost in application code. They shine in library code where you're writing the abstraction once and consuming it many times. In an app, they mostly confuse the next person reading the file.
The order that works
-
strictandnoUncheckedIndexedAccess - Union types with discriminants
-
unknownat boundaries - Type guards and
satisfies - The four utility types above
Get comfortable with these five and you'll catch the majority of runtime bugs TypeScript is good at preventing. The advanced stuff can wait until you have a concrete problem it solves.
Top comments (2)
The ordering argument is right, and the reason it works is that the first two items fail loudly while the rest fail quietly. strict plus a discriminant union turns a whole class of runtime undefined into a compile error you see on day one. Template literal types give you the opposite trade: the abstraction is invisible at the call site and only the next reader of the file pays for it.
The one I'd promote higher is
unknownat boundaries, because it's the only item here that documents intent rather than enforcing shape - the guard you write next to it is where the actual domain model shows up. Did you findnoUncheckedIndexedAccesssurvivable on array-heavy code, or did the tuple-destructuring noise push you to scope it to a few directories?Some comments may only be visible to logged-in visitors. Sign in to view all comments.