TypeScript Best Practices

Reviewed & published by Brayan K

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.

Part of the free TypeScript course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

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.

// 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 blows up:
  return raw.user.name.toUpperCase();
}

// ✅ AFTER: typed as 'unknown' — you MUST narrow before using it.
//    function parse(raw: unknown): string {
//      if (typeof raw === "object" && raw !== null && "user" in raw) { ... }
//    }
function parseSafe(raw) {
  // 'unknown' forces these checks. Each one is a type guard that
  // narrows the value until it is safe to touch.
  if (
    typeof raw === "object" && raw !== null &&
    "user" in raw && typeof raw.user === "object" && raw.user !== null &&
    "name" in raw.user && typeof raw.user.name === "string"
  ) {
    return raw.user.name.toUpperCase();   // safe: we proved name is a string
  }
  return "UNKNOWN USER";                  // a real, handled fallback
}

const good = { user: { name: "ada" } };
const bad  = { user: null };

console.log("parseSafe(good):", parseSafe(good));   // ADA
console.log("parseSafe(bad): ", parseSafe(bad));    // UNKNOWN USER (no crash)

try {
  console.log(parseUnsafe(bad));          // 'any' let this through...
} catch (e) {
  console.log("parseUnsafe(bad) CRASHED:", e.message); // ...and it dies here
}

// Lesson: 'any' moves the bug from compile time to a 2am production crash.
// 'unknown' forces you to handle reality up front.

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 — 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 number here
  }

  // 2) Then narrow to a string.
  if (typeof value === ___) {        // 👉 the word "string" in quotes
    return value.trim().toUpperCase();
  }

  // 3) Everything else: a real fallback (never reach in unchecked).
  return ___;                        // 👉 a string, e.g. "N/A"
}

console.log(format(9.5));      // £9.50
console.log(format("  hi "));  // HI
console.log(format(true));     // N/A

// ✅ Expected output:
// £9.50
// HI
// N/A

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.

// 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 = 3;
const total = price * qty;     // inferred: number
console.log(name, tags, total);// Alice [ 'a', 'b' ] 29.97

// 👍 DO annotate the BOUNDARIES — places inference can't see your intent:
//   1) function parameters (no value to infer from)
//   2) the public return type of an exported function (locks the contract)
//   3) an empty container you'll fill later: const ids: number[] = [];
function add(a, b) { return a + b; }   // params would be ': number' in TS
console.log("add(2,3):", add(2, 3));   // 5

// Rule of thumb: annotate where data ENTERS your code; infer everywhere inside.

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.

// 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 !== "delete";
  return action === "read";              // viewer: read-only
}

for (const r of ROLES) {
  console.log(r, "can delete? ", can(r, "delete"));
}
// admin can delete?  true
// editor can delete?  false
// viewer can delete?  false

// Why not 'enum Role { Admin, Editor, Viewer }'?
//  - a numeric enum emits real JS objects (extra bundle weight)
//  - 'Role.Admin' serialises to 0, which is meaningless in your API/JSON
//  - unions are simpler and play nicely with data you already have
// Use enum only when you truly need a named, iterable runtime construct.

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, 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 utility type that REMOVES a key is  Omit   (Pick keeps; Omit drops)

// 2) A patch that updates SOME fields. In TS you'd write:
//    type UserPatch = ___<NewUser>;
//    👉 the utility type that makes every field optional is  Partial

// 3) Lock a config so it can't be mutated. In TS:  as ___
//    👉  as const   freezes the object's values and widens nothing.
const CONFIG = { retries: 3, baseUrl: "/api" };   // add "as const" in TS

// The runtime demo below just proves the shapes line up:
const newUser = { name: "Sam", email: "[email protected]" };       // NewUser (no id)
const patch   = { email: "[email protected]" };                  // UserPatch (partial)
const saved   = { id: "u_1", ...newUser, ...patch };       // a full User
console.log(saved);
console.log("retries:", CONFIG.retries);

// ✅ Expected output:
// { id: 'u_1', name: 'Sam', email: '[email protected]' }
// retries: 3

Common Errors & Anti-Patterns

📋 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: 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.data.length + " items";
//    "error"   -> return "Error: " + result.message;
// 3. Call it three times, once per status, and console.log each.
//
// ✅ Expected output:
// Loading...
// Got 2 items
// Error: timeout

// your code here

🎉 Lesson Complete — and Course Complete!

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

Practice quiz

What is the single highest-value tsconfig setting?

  • "skipLibCheck": true
  • "target": "ES2020"
  • "strict": true
  • "module": "ESNext"

Answer: "strict": true. "strict": true switches on a whole family of checks (strictNullChecks, noImplicitAny, and more) - the biggest single win.

Which strict sub-flag stops TypeScript silently falling back to any?

  • noImplicitAny
  • strictNullChecks
  • noUnusedLocals
  • noImplicitReturns

Answer: noImplicitAny. noImplicitAny makes TypeScript refuse to silently use any, forcing you to type values explicitly.

What is the recommended replacement for any when input is genuinely uncertain?

  • never
  • void
  • object
  • unknown + narrowing

Answer: unknown + narrowing. unknown accepts any value but forces you to narrow before use, keeping the compiler working for you - unlike any.

Which is the over-annotated, redundant style to avoid?

  • const name = "Alice"
  • const name: string = "Alice"
  • function add(a: number, b: number)
  • const ids: number[] = []

Answer: const name: string = "Alice". const name: string = "Alice" repeats what inference already knows. Annotate boundaries (params, exported returns), not obvious values.

Where SHOULD you add type annotations?

  • At boundaries: function parameters and exported return types
  • On every variable
  • Only on numbers
  • Never

Answer: At boundaries: function parameters and exported return types. Annotate the edges - parameters and exported return types - where inference can't read your intent; infer everywhere inside.

What does as const do to an object literal?

  • Deletes its properties
  • Converts it to a class
  • Freezes its values into exact, narrow, read-only types
  • Makes every field optional

Answer: Freezes its values into exact, narrow, read-only types. as const freezes a literal so values become exact, narrow, read-only types instead of being widened to string/number.

For a fixed set of options like roles, which is usually preferred over an enum?

  • A numeric enum
  • A string-literal union type
  • An array of any
  • A class hierarchy

Answer: A string-literal union type. A string-literal union (type Role = "admin" | "editor") has zero runtime footprint, serialises cleanly, and autocompletes.

Why prefer @ts-expect-error over @ts-ignore?

  • It is shorter to type
  • It disables checking for the whole file
  • It works only in strict mode
  • It errors once the underlying issue is fixed, so stale suppressions can't pile up

Answer: It errors once the underlying issue is fixed, so stale suppressions can't pile up. @ts-expect-error itself errors when the suppressed problem is gone, preventing stale suppressions; @ts-ignore hides forever.

How should you derive a 'new user' shape that is User without the id field?

  • Pick<User, "id">
  • Omit<User, "id">
  • Partial<User>
  • Required<User>

Answer: Omit<User, "id">. Omit<T, Keys> drops the listed keys, so Omit<User, "id"> is User without id. Pick keeps; Omit drops.

What is the downside of using a type assertion like value as User?

  • It is slower at runtime
  • It only works on numbers
  • It tells the compiler something without proving it - if you're wrong, it lies
  • It always throws an error

Answer: It tells the compiler something without proving it - if you're wrong, it lies. An assertion asserts without verifying. Prefer a real type guard so the compiler actually checks the claim.

Continue this course

Related lessons