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.
Part of the free TypeScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, 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.
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.
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.
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.
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 .
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.
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.
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.
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 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
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.
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.
🎉 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
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)
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