TypeScript in practice

Use it the way a working codebase does: strictly at the edges, lightly in the middle, and honestly about what it cannot know.

14of 14
≈ 13Min read
8Parts

Types end where your program does

Inside the line, a type is a fact the compiler has verified. Outside it, a type is an assumption. Nearly every "TypeScript lied to me" story is a value that crossed the line without being checked.

the one diagram worth keeping
            outside            │            inside
  network, form, storage,     │   your functions, your state,
  URL, worker message,        │   your components
  a library's any             │
  ────────── validate ────────┼──── types are enough ──────
  zod / valibot / a predicate │   no runtime checks needed

Validate once, at the edge

and give the rest of the program a real type
const DoorSchema = z.object({
  id: z.string(),
  name: z.string().min(2),
  days: z.number().int().min(1).max(365),
  price: z.number().positive(),
});

export type Door = z.infer<typeof DoorSchema>;    // one declaration, both jobs

export async function loadDoor(id: string): Promise<Door> {
  const res = await fetch(`/api/doors/${id}`);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);

  return DoorSchema.parse(await res.json());      // throws with a useful message
}

The four escape hatches, and what each costs

What it doesWhen
asSilences one checkYour own markup, a library that is too loose
as anyTurns checking off, and it spreadsAlmost never — prefer as unknown as T
!Asserts not-nullWhen you have just checked, one line above
@ts-expect-errorAllows the next line to failWith a comment saying why — and it errors when the problem is fixed
Prefer @ts-expect-error to @ts-ignore

They do the same thing, except that expect-error fails the build once the line stops erroring — so it cleans itself up instead of hiding a fix forever.

Migrating JavaScript

  • Rename leaf files first — the ones nothing imports. Then work up towards the shared model.
  • // @ts-check at the top of a .js file gives you most of the value with no rename at all.
  • Type the shared shapes first: the model, the API responses. They pay for themselves everywhere.
  • Do not chase any out of a file you are not otherwise touching. Convert as you change.
the order that works
{
  "allowJs": true,          // 1. compile the JS as it is
  "checkJs": false,         // 2. turn on per file with // @ts-check
  "strict": false           // 3. then strict, one flag at a time
}

Types are not tests, and tests are not types

A type proves the shape; a test proves the behaviour. Replacing a test with a type loses the behaviour, and replacing a type with a test means writing the same assertion in thirty places.

What types do remove is a whole genus of test: the ones that assert an argument is a string, or that a function was called with the right shape. Keep the tests about what the code decides.

Habits worth having

  • Annotate the boundaries — exported functions, module edges — and let the middle infer.
  • Let the model be one type, derived everywhere else with Omit, Pick and Partial.
  • Reach for a union of literals instead of a string whenever a value is "one of these".
  • Make impossible states impossible: a discriminated union rather than four booleans.
  • Delete a type parameter that appears once, and a cast you cannot justify in a sentence.

And the ones to avoid

each of these appears in every large codebase
function handle(data: any) { … }              // it spreads from here
const door = data as Door;                     // a wish about a server response
// @ts-ignore                                  // no reason, no expiry
type Props = { [key: string]: any };           // a type that says nothing
enum Finish { Painted, Oiled }                 // prefer a union of literals
Why not enum

A numeric enum is not type-safe (any number assigns to it), and both kinds emit real JavaScript, which is why a union of string literals with as const is the modern default.

Where it actually pays

The value is not in the annotations; it is in the refactor. When the model gains a field, the compiler hands you the list of everything that must change — and that list is complete, which no search and no memory ever is.

That is the whole argument. Everything else in this course is how to keep that list accurate.

Practice