TypeScript Best Practices
Learn conventions and habits for writing maintainable, strict TypeScript as a project grows.
Reading Time
16 min
Lesson
Lesson 30 of 30
Start Strict, Stay Strict
Enable "strict": true from day one. Retrofitting strict mode onto a large, loosely-typed codebase later requires fixing hundreds of newly surfaced errors at once — starting strict spreads that cost out naturally as you write each file.
General Guidelines
- Avoid any — prefer unknown plus narrowing for genuinely unknown data.
- Let inference handle simple local variables; annotate function parameters explicitly.
- Prefer discriminated unions over loosely related optional properties.
- Reach for a utility type (Partial, Pick, Omit) before writing a near-duplicate interface.
- Use type predicates (is Type) to centralize repeated narrowing logic.
- Avoid type assertions (as) except at true boundaries, like typed DOM access.
Model Your Domain with Types
Instead of a single loosely-typed object with many optional fields, model distinct states as a discriminated union — it makes invalid states genuinely unrepresentable, not just discouraged by convention.
Loose vs Modeled State
// Loose: many fields could be missing or contradictory
interface RequestState {
loading: boolean;
data?: string;
error?: string;
}
// Modeled: each state is explicit and mutually exclusive
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };Avoid Over-Engineering Types
Not every value needs a named type or a generic utility. A quick inline object type for a single-use function parameter is often clearer than a one-off named type that’s referenced nowhere else.
Validate at the Boundary
TypeScript types disappear at runtime. Data coming from outside your program — API responses, form input, environment variables — should be validated (with a type guard or a library like Zod) at the point it enters your system, not just assumed to match its declared type.
Validating at a Boundary
interface User {
id: number;
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
typeof (value as User).id === "number" &&
typeof (value as User).name === "string"
);
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
if (!isUser(data)) {
throw new Error("Invalid user response from API");
}
return data; // safely typed as User from here on
}A Common Trap: Trusting fetch()’s Type
response.json() is typed as Promise<any> by default — TypeScript will happily let you treat the result as any shape you claim, even if the API actually returns something completely different. Always validate, don’t just assert.
Best Practice
Treat the type system as a tool for making illegal states unrepresentable, not just documentation. The stricter and more precise your types are, the more bugs TypeScript catches for you before your code ever runs.
Summary
Great TypeScript isn’t about typing every single thing exhaustively — it’s about modeling your actual domain accurately, keeping strict mode on, validating data at the edges, and trusting inference for everything else.