Lesson 8 • Advanced
TypeScript Best Practices 🏆
By the end of this lesson you'll write TypeScript the way professional teams do: strict mode on, no stray any, types inferred where they can be and locked down where it matters — code that catches bugs at compile time instead of in production.
What You'll Learn
- Turn on strict mode — and know what each flag actually buys you
- Replace any with unknown + narrowing so the compiler keeps working for you
- Let inference do its job and stop over-annotating obvious values
- Lock data down with readonly and as const for safer immutability
- Choose string-literal unions over enums (and know the rare exception)
- Type your function boundaries and derive shapes with utility types
any you sprinkle in is unbuckling it "just for a second."1. Turn On Strict Mode
The single highest-value thing you can do is set "strict": true in your tsconfig.json. That one flag switches on a whole family of checks — including strictNullChecks (you can't accidentally use a value that might be null) and noImplicitAny (TypeScript refuses to silently fall back to any). Without strict mode, TypeScript is barely more than a fancy linter; with it, it actually proves your code can't hit whole classes of bugs.
Here's a sensible starting tsconfig.json. Copy it, then read why each line earns its place.
{
"compilerOptions": {
"strict": true, // ✅ the big one: turns on all strict checks
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined, not just T
"noImplicitReturns": true, // every code path must return
"noFallthroughCasesInSwitch": true, // a missing 'break' is an error
"noUnusedLocals": true, // dead variables become errors
"forceConsistentCasingInFileNames": true, // ./User vs ./user can't differ
"target": "ES2020",
"module": "ESNext",
"skipLibCheck": true // don't re-check node_modules .d.ts files
}
}strict for new files, or enable the sub-flags one at a time (strictNullChecks first) and fix as you go.2. Avoid any — Reach for unknown
any means "turn off type checking for this value." It's contagious: anything you touch through an any becomes any too, and your safety net quietly disappears. The honest alternative is unknown — it also accepts any value, but it won't let you do anything with that value until you've narrowed it (proved what it is with a check like typeof x === "string"). Run the worked example below: it shows the exact runtime crash that any waves through and unknown prevents.
Worked example: the any → unknown refactor
See the crash any allows, and how narrowing unknown prevents it.
// The 'any' escape hatch turns TypeScript OFF for that value.
// Here is the SAME logic, before and after, run as plain JS so you
// can see the runtime behaviour the types are protecting you from.
// ❌ BEFORE: typed as 'any' — the compiler stops checking.
// function parse(raw: any) { <-- 'any' = "trust me"
// return raw.user.name.toUpperCase(); <-- no warning, may crash
// }
function parseUnsafe(raw) {
// With 'any', TS lets you reach into anything. At runtime it blow
...Your turn. The function below receives an unknown value and must narrow it before use. Fill in the three blanks marked ___ using the hints, then run it and check the output.
🎯 Your turn #1: replace any with narrowing
Narrow the unknown value to number, then string, then fall back.
// 🎯 YOUR TURN #1 — kill the 'any'. Replace each ___ then press Run.
// This file runs as JS; the comments show the TypeScript you'd write.
// A function received an 'unknown' value from JSON.parse(...).
// In TS its signature is: function format(value: unknown): string
function format(value) {
// 1) Narrow to a number FIRST. Fill in the type to compare against.
if (typeof value === ___) { // 👉 the word "number" in quotes
return "£" + value.toFixed(2); // safe: value is a nu
...3. Let Inference Work — Don't Over-Annotate
TypeScript reads your values and figures out their types automatically. Writing const name: string = "Alice" is just repeating yourself — const name = "Alice" already gives name the type string. Over-annotating adds noise and, worse, can drift out of sync with the real value. The skill is knowing where to annotate: at the boundaries (function parameters and exported return types) where TypeScript can't read your intent.
Worked example: infer inside, annotate at the edges
Where to drop annotations and where to keep them.
// Let inference work. Re-typing what TS already knows is just noise.
console.log("=== Don't repeat what TypeScript can see ===");
// ❌ Over-annotated — every ': type' here is redundant:
// const name: string = "Alice";
// const tags: string[] = ["a", "b"];
// const total: number = price * qty;
// ✅ Inferred — TS reads the value and gives it the exact type:
const name = "Alice"; // inferred: string
const tags = ["a", "b"]; // inferred: string[]
const price = 9.99, qty =
...4. Immutability: readonly and as const
If a value shouldn't change, say so in the type — the compiler will then stop anyone (including future-you) from mutating it by accident. readonly marks an individual property or a whole array as look-but-don't-touch. as const goes further: it freezes an object/array literal so its values become exact, narrow, read-only types instead of being widened to string or number.
// readonly on a property and on an array
interface Point { readonly x: number; readonly y: number; }
const p: Point = { x: 1, y: 2 };
// p.x = 9; // ❌ error: x is read-only
function sum(nums: readonly number[]) {
// nums.push(0); // ❌ error: can't mutate a readonly array
return nums.reduce((a, b) => a + b, 0);
}
// as const freezes a literal and narrows its types:
const ROUTES = ["/home", "/about"] as const;
// type is readonly ["/home", "/about"] — not string[]
type Route = (typeof ROUTES)[number]; // "/home" | "/about"
const CONFIG = { env: "prod", retries: 3 } as const;
// CONFIG.retries = 5; // ❌ error: read-only5. Prefer Unions Over Enums
For a fixed set of options, a string-literal union like type Role = "admin" | "editor" | "viewer" is usually the better tool than an enum. Unions have zero runtime footprint (an enum emits real JavaScript), the values are just the strings — so they read cleanly in logs, JSON, and APIs — and they autocomplete everywhere. Run this to see a union in action, then read the note on when an enum still earns its keep.
Worked example: a string-literal union
A role check built on a union, plus why enums are usually overkill.
// Prefer a string-literal UNION over an enum for most "one of these" sets.
console.log("=== Union of string literals ===");
// ✅ A union type (in TS): type Role = "admin" | "editor" | "viewer";
// - zero runtime code - autocompletes - values ARE the strings
const ROLES = ["admin", "editor", "viewer"];
function can(role, action) {
// Each branch is checked against the union; a typo like "admn" is a TS error.
if (role === "admin") return true;
if (role === "editor") return action !
...6. Type Your Boundaries & Derive With Utility Types
Annotate the edges of your code — function parameters and exported return types — because that's the contract other code relies on. And when you need a variation of a shape you already have, derive it with a utility type instead of re-typing the fields by hand. The big four: Partial<T> (all fields optional), Pick<T, K> (keep some keys), Omit<T, K> (drop some keys), and Record<K, V> (a map). Derived types stay in sync automatically when the source changes.
// One source of truth:
interface User { id: string; name: string; email: string; role: "admin" | "user"; }
// Derive everything else from it — never re-type the fields:
type NewUser = Omit<User, "id">; // everything except id
type UserPatch = Partial<NewUser>; // every field optional (for updates)
type UserCard = Pick<User, "name" | "role">; // just the bits a card shows
type UsersById = Record<string, User>; // { [id]: User }
// Type the BOUNDARY of an exported function (params + return):
export function createUser(input: NewUser): User {
return { id: crypto.randomUUID(), ...input };
}Now you try. Fill in the utility-type names in the comments below, then run it to confirm the derived shapes line up at runtime.
🎯 Your turn #2: derive types, don't duplicate
Name the right utility types (Omit, Partial) and use as const.
// 🎯 YOUR TURN #2 — derive, don't duplicate. Fill in the ___ comments.
// (Runs as JS; the // TS: lines show the real TypeScript you'd write.)
// You already have one source-of-truth shape:
// interface User { id: string; name: string; email: string; }
//
// You need a "new user" shape that is User WITHOUT the id.
// Don't re-type the fields by hand — derive it with a utility type.
// 1) The form that creates a user. In TS you'd write:
// type NewUser = ___<User, "id">;
// 👉 the uti
...Common Errors & Anti-Patterns
- "Object is possibly 'null'" (TS2531): strict mode caught a value that might be
null. That's a real bug — guard it withif (x) { ... }or use optional chainingx?.name. Don't silence it withx!unless you truly know it's set. - Using
anyto make an error go away: you didn't fix the bug, you hid it. Useunknownand narrow, or fix the upstream type. @ts-ignoreon the line above an error: it suppresses forever, even after the bug is fixed. Use@ts-expect-errorinstead — it errors once the underlying issue is gone, so stale suppressions can't pile up.- Type assertions everywhere (
value as User): an assertion tells the compiler something without proving it — if you're wrong, it lies. Prefer a real check (a type guard) so the compiler verifies it. - "Property does not exist on type '{}'" (TS2339): you're reaching into an empty/over-narrow type. Give the parameter a proper type at the boundary instead of casting your way through.
- One giant
types.tswith duplicated shapes: split by domain (user.ts,api.ts) and derive variants withPartial/Pick/Omitrather than copy-pasting fields.
📋 Quick Reference — Do / Don't
| Topic | ❌ Don't | ✅ Do |
|---|---|---|
| Config | "strict": false | "strict": true |
| Unknown input | raw: any | raw: unknown + narrow |
| Obvious values | const n: string = "a" | const n = "a" |
| Fixed value | let MAX = 3 | const MAX = 3 / as const |
| Option set | enum Role {...} | type Role = "a" | "b" |
| Shape variant | re-declare fields | Omit/Pick/Partial |
| Suppress error | @ts-ignore | @ts-expect-error |
Frequently Asked Questions
Q: Is any ever OK?
Rarely, and always as a deliberate, commented escape hatch — e.g. when migrating untyped JavaScript. Even then unknown is usually the better choice because it forces a narrowing check before use. Treat each any as a TODO, not a solution.
Q: What's the difference between type and interface?
For object shapes they're nearly interchangeable. interface can be re-opened and merged and reads well for public object contracts; type can also express unions, tuples, and mapped/utility types. A common rule: interface for object shapes, type for unions and derived types. Be consistent within a project.
Q: When should I actually use an enum?
When you genuinely need a named, iterable runtime construct — e.g. you want to loop over all values, or you're matching a numeric protocol. For a plain "one of these strings," a union is lighter and serialises cleanly. If you do reach for one, prefer a const enum or a string enum.
Q: Should I annotate every function's return type?
For exported functions, yes — the annotation locks the public contract and gives faster, clearer errors. For small internal helpers, letting TypeScript infer the return is fine and keeps the code tidy.
Mini-Challenge: Type-Safe Result Handler
No blanks this time — just a brief and an outline. Model a fetch result as a discriminated union and handle every case exhaustively. Build it, run it, and check your output against the comments. This is the bread-and-butter pattern behind every loading spinner you've ever seen.
🎯 Mini-Challenge: handle every status
Write render(result) and call it for loading, success, and error.
// 🎯 MINI-CHALLENGE: a type-safe API result handler
// Model a fetch result as a discriminated union and handle every case.
//
// In TypeScript you'd define:
// type Result =
// | { status: "loading" }
// | { status: "success"; data: string[] }
// | { status: "error"; message: string };
//
// Your job (here, as runnable JS):
// 1. Write a function render(result) that switches on result.status.
// 2. "loading" -> return "Loading...";
// "success" -> return "Got " + result.da
...🎉 Lesson Complete — and Course Complete!
- ✅
"strict": trueis the single highest-value setting — turn it on everywhere - ✅ Replace
anywithunknown+ narrowing so the compiler keeps protecting you - ✅ Let inference work; annotate the boundaries (params, exported returns)
- ✅ Use
readonlyandas constto make "shouldn't change" enforceable - ✅ Prefer string-literal unions over enums for fixed option sets
- ✅ Derive shapes with
Partial/Pick/Omit/Record— one source of truth
🚀 Where to go next
You've finished the TypeScript course! To turn knowledge into instinct: (1) flip on strict in a real project and fix what lights up; (2) try a typed framework — pair this with our TypeScript with React lesson and build a small app; (3) explore the standard library types in the official TypeScript Utility Types handbook; and (4) read a strict, well-typed open-source codebase to see these habits at scale. Keep building — the types will start writing themselves.
Sign up for free to track which lessons you've completed and get learning reminders.