Best Practices

This final lesson gathers the habits that separate TypeScript that merely compiles from TypeScript that genuinely prevents bugs. Most come down to one idea: make the types describe reality as precisely as possible, so the compiler can do more of the checking for you.

1. Turn on strict mode — and keep it on

"strict": true is the baseline for any new project (lesson 38). Consider noUncheckedIndexedAccess as well. Loosening the compiler to make an error go away usually just moves the bug to runtime.

2. Let inference work; annotate boundaries

Annotate function parameters, exported function return types and empty collections. Let TypeScript infer local variables and callback parameters. Redundant annotations add noise and can even widen types you'd rather keep precise.

TS inference.ts
// ✗ noisy
const count: number = 0;
const names: string[] = ["a", "b"].map((s: string): string => s.toUpperCase());

// ✓ annotate the boundary, infer the rest
export function initials(fullNames: string[]): string[] {
  return fullNames.map(n => n.split(" ").map(part => part[0]).join(""));
}

console.log(count, names, initials(["Ada Lovelace", "Alan Turing"]));
Output
0 [ 'A', 'B' ] [ 'AL', 'AT' ]

3. Prefer unknown over any

any switches checking off and spreads silently; unknown forces a check before use (lesson 05). Treat every any in a code review as a question that needs an answer, and enable the no-explicit-any lint rule.

4. Validate data at the edges

Types disappear at runtime. Anything from outside your program — API responses, form input, environment variables, files, localStorage — should be parsed and validated once, at the point it enters, with a type guard or schema library (lesson 37). After that, the rest of your code can trust its types completely.

5. Make impossible states impossible

A type with lots of optional properties allows combinations that should never happen. A discriminated union rules them out:

TS states.ts
// ✗ allows { loading: true, error: "x", data: [...] } — what would that mean?
type LooseState = { loading: boolean; error?: string; data?: string[] };

// ✓ each state carries exactly what it needs
type State =
  | { status: "loading" }
  | { status: "error"; error: string }
  | { status: "ready"; data: string[] };

function view(s: State) {
  switch (s.status) {
    case "loading": return "…";
    case "error": return `⚠ ${s.error}`;
    case "ready": return s.data.join(", ");
  }
}

console.log(view({ status: "ready", data: ["a", "b"] }));
Output
a, b

Pair this with exhaustive switch statements (lesson 23) so adding a new state shows you every place that needs updating.

6. Use literal unions for fixed sets of values

status: string accepts typos; status: "draft" | "published" doesn't. When you also need the values at runtime, derive the type from an as const array (lesson 24) so there's one source of truth.

7. Derive types instead of duplicating them

If one type is "the same as that one, but…", express that relationship with Pick, Omit, Partial, ReturnType, typeof or indexed access. Duplicated types drift apart; derived types update automatically.

TS derive.ts
interface Product {
  id: string;
  name: string;
  priceCents: number;
  createdAt: Date;
}

type NewProduct = Omit<Product, "id" | "createdAt">;
type ProductUpdate = Partial<NewProduct>;

const draft: NewProduct = { name: "Mug", priceCents: 1200 };
const patch: ProductUpdate = { priceCents: 999 };
console.log({ ...draft, ...patch });
Output
{ name: 'Mug', priceCents: 999 }

8. Be careful with assertions

as, ! and @ts-expect-error all tell the compiler to trust you instead of checking. Each one is a place where a bug can hide. Prefer a narrowing check, satisfies (lesson 14) or a better type. When an assertion is genuinely needed, keep it as close to the source of the knowledge as possible and leave a comment explaining why it's safe.

9. Keep types readable

Advanced type-level programming is powerful, but a type nobody on the team can read is a maintenance cost. Give complex types descriptive names, split them into steps, and prefer a slightly less precise but obvious type over a clever one in application code. Save conditional and recursive types for shared utilities and libraries.

10. Prefer modern, erasable syntax

For new code, favour ES modules over namespaces, literal unions or as const objects over enums, and explicit property declarations over parameter properties. They work with every tool — including Node's built-in type stripping — and keep your TypeScript as close to plain JavaScript as possible (lesson 44).

11. Automate the checks

Run tsc --noEmit, the linter and tests in CI on every change (lesson 41). Type safety you have to remember to check is type safety you'll eventually skip.

Where to go from here

  • Test yourself with the exercises and the quiz.
  • Follow the study plan to review anything that didn't stick.
  • Put TypeScript to work in a framework: the React, Angular, Vue and Node.js courses all build on what you've learned here.