TypeScript Best Practices
Practical habits that keep a TypeScript codebase strict, readable, and actually type-safe.
3 min read
TypeScript's type system only catches bugs when it's actually configured and used strictly. This lesson closes out the course with habits that keep a codebase genuinely safer, rather than just decorated with annotations.
Keep strict mode on
{
"compilerOptions": {
"strict": true
}
}Covered back in the setup lesson, and worth repeating as the single highest-leverage setting in the entire language: strict bundles together noImplicitAny, strictNullChecks, and several other checks that together are responsible for most of the real bugs TypeScript catches. A non-strict TypeScript codebase gets much weaker guarantees than the language is actually capable of.
Avoid any; reach for unknown or a real type instead
As covered earlier, any opts a value — and everything it touches — out of type checking entirely. Prefer unknown at boundaries where the type genuinely isn't known yet, and narrow from there.
// Avoid
function process(data: any) { /* no safety anywhere in here */ }
// Prefer
function process(data: unknown) {
if (typeof data === "string") { /* narrowed, safe */ }
}Let inference work; annotate where it can't
Don't annotate a variable that's initialized immediately — TypeScript infers it correctly, and the annotation is just noise:
// Redundant
const name: string = "Ada";
// Same result, no annotation needed
const name = "Ada";Do annotate function parameters (TypeScript can't infer those from nothing) and the return types of exported/public functions, where an explicit contract helps both readers and catches accidental changes to what a function returns.
Prefer unions over enums for new code
As covered in the enums lesson, a string-literal union gives you the same compile-time safety as an enum with no runtime code generated at all, and it composes more naturally with narrowing and discriminated unions.
Model impossible states as impossible
// Avoid -- allows nonsensical combinations, like loading: true with data present
interface State {
loading: boolean;
data?: string;
error?: string;
}
// Prefer -- a discriminated union makes invalid combinations unrepresentable
type State =
| { status: "loading" }
| { status: "success"; data: string }
| { status: "error"; error: string };The second version, using the discriminated union pattern from earlier in this course, makes it impossible to accidentally represent a state that shouldn't exist — there's no way to have data without status: "success", enforced entirely by the type system rather than by convention or a runtime check.
Don't fight the compiler with assertions
const el = document.getElementById("app") as HTMLElement; // asserts, doesn't check
el.focus(); // crashes at runtime if the element doesn't actually existA type assertion (as) tells TypeScript "trust me" without any actual runtime check — it silences the compiler but doesn't make the underlying risk go away. Prefer a real check (if (el) { ... }) wherever the value might genuinely be missing, and reserve assertions for cases where you have information TypeScript truly can't infer on its own.
Putting the course together
Across this course, the throughline has been the same: TypeScript's type system is only as useful as the precision you put into it. Loose typing (any everywhere, strict turned off) gives you a slower version of plain JavaScript with none of the real benefit. Used deliberately — strict mode on, unknown over any, types modeling exactly what's valid rather than everything that's merely possible — it turns an entire category of runtime bugs into compile-time errors you fix before they ever ship.