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.
Learn Python, JavaScript, Java and more with free interactive lessons, real projects and AI-powered help. Beginner-friendly.
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.
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.
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.
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.
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.