TypeScript Best Practices for Clean, Maintainable Code
TypeScript works best when it reduces ambiguity, not when it adds ceremony. The goal isn’t to type everything — it’s to make wrong code hard to write and correct code easy to change. A few disciplined defaults can turn a messy JavaScript project into a calm, predictable system.
Rules that actually pay off
- Enable
strictmode on day one. Fixing types late is always more expensive. - Prefer obvious, boring types over clever, compressed ones.
- Add explicit return types to public functions and APIs.
- Use
unknowninstead ofanyand prove what you receive. - Keep types close to where they’re used. Global types decay fast.
If a type needs a long comment to explain it, the type is badly designed.
Model real states, not wishful ones
Most bugs come from pretending values always exist or are always valid.
Bad:
function getUserName(user?: { name?: string }) { return user!.name!; }
Good:
type User = | { kind: "guest" } | { kind: "registered"; name: string }; function getUserName(user: User): string { return user.kind === "registered" ? user.name : "Guest"; }
Now impossible states are gone. The compiler forces you to handle reality.
One pattern worth memorizing
Don’t throw exceptions for normal failure paths. Make failure part of the type.
type Result<T, E = string> = | { ok: true; value: T } | { ok: false; error: E }; export function parseNumber(input: string): Result<number> { const n = Number(input); return Number.isFinite(n) ? { ok: true, value: n } : { ok: false, error: "Not a valid number" }; }
Why this matters:
- No hidden control flow
- No try/catch soup
- Errors become part of the contract
- Callers must handle failure
Prefer boring, readable types
Clever types feel smart today and become technical debt tomorrow.
Avoid:
- Deep conditional types
- Excessive generics
- Heavy function overloads
Prefer:
type UserId = string; type Email = string; interface User { id: UserId; email: Email; isActive: boolean; }
Simple wins.
Lock down your boundaries
Anything coming from the outside world is untrusted until proven otherwise.
function isUser(input: unknown): input is User { return ( typeof input === "object" && input !== null && "id" in input && "email" in input ); }
Validate once at the edge. Treat everything inside your app as safe and typed.
Wrap-up
Good TypeScript is boring in the best way:
- Types match real business states
- Impossible states are removed
- Errors are explicit
- APIs are predictable
- Refactors don’t feel dangerous
If TypeScript feels painful, your types are lying about reality — and the bugs are just proving it.