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
- 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.
// 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/A3. 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-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.
// 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: 3Common 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 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: 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!
- ✅ "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.
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
- Previous: TypeScript with React
- Next: Runtime Validation with Zod — Validate data at runtime and infer static types from a single Zod schema
- Quick reference: TypeScript cheat sheet