Skip to main content
    Courses/TypeScript/TypeScript Best Practices

    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

    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
      }
    }

    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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 🎯 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.

    Try it Yourself »
    JavaScript
    // 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-only

    5. 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.

    Try it Yourself »
    JavaScript
    // 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.

    Try it Yourself »
    JavaScript
    // 🎯 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 with if (x) { ... } or use optional chaining x?.name. Don't silence it with x! unless you truly know it's set.
    • Using any to make an error go away: you didn't fix the bug, you hid it. Use unknown and narrow, or fix the upstream type.
    • @ts-ignore on the line above an error: it suppresses forever, even after the bug is fixed. Use @ts-expect-error instead — 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.ts with duplicated shapes: split by domain (user.ts, api.ts) and derive variants with Partial/Pick/Omit rather than copy-pasting fields.

    📋 Quick Reference — Do / Don't

    Topic❌ Don't✅ Do
    Config"strict": false"strict": true
    Unknown inputraw: anyraw: unknown + narrow
    Obvious valuesconst n: string = "a"const n = "a"
    Fixed valuelet MAX = 3const MAX = 3 / as const
    Option setenum Role {...}type Role = "a" | "b"
    Shape variantre-declare fieldsOmit/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.

    Try it Yourself »
    JavaScript
    // 🎯 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": true is the single highest-value setting — turn it on everywhere
    • ✅ Replace any with unknown + narrowing so the compiler keeps protecting you
    • ✅ Let inference work; annotate the boundaries (params, exported returns)
    • ✅ Use readonly and as const to 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.

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service