TypeScript in practice

Typing patterns and smells

[object Object]

"Review this TypeScript" is the format that separates fluency from familiarity. The code compiles; the question is what will bite. This page lists the patterns that make a codebase pleasant and the smells that predict incidents, so you can name them quickly and explain the fix.

Know — patterns

  • Discriminated unions for state and variants instead of optional fields and booleans; the kind property drives exhaustive switches and narrows in templates.
  • Const objects instead of enums: const ROLE = { admin: 'admin', editor: 'editor' } as const; type Role = typeof ROLE[keyof typeof ROLE]; zero runtime, erasable, iterable (Object.values(ROLE)).
  • Derive from values: keyof typeof, typeof x[number], ReturnType<typeof createStore>, z.infer; types written once, in one direction.
  • Result and typed errors for expected failures; exceptions for the unexpected; never for exhaustiveness.
  • Function-type utilities: Parameters, ReturnType, Awaited, NoInfer, satisfies for tables and configs, labelled tuples for emits and callbacks.
  • Small, explicit public types: exported functions carry explicit return types (also required for isolatedDeclarations), internal helpers infer; interfaces for object shapes that may be extended, type aliases for unions and utilities.
  • Branded primitives for ids, currency codes and validated strings; readonly on inputs you do not mutate (readonly T[]), which also documents intent.
  • Type-level tests with expectTypeOf (Vitest) or // @ts-expect-error lines next to the code, so a typing regression fails CI.
  • Lint the types: @typescript-eslint with strict-type-checked: no-explicit-any, no-unsafe-*, no-floating-promises, no-non-null-assertion, consistent-type-imports, switch-exhaustiveness-check.

Know — smells

  • as casts on data you did not construct (as Product, as HTMLInputElement without checking, as any, as unknown as T): each one is a place where the type and the runtime can disagree silently. Replace with guards, schemas, or satisfies.
  • Non-null assertions (!) sprinkled to silence strictNullChecks: a crash waiting for the one path where the value is null. Narrow explicitly or restructure so the value cannot be missing.
  • any leakage: an any return from a helper (JSON.parse, an untyped SDK, a catch (e: any)) spreads into everything downstream. Wrap the seam in a typed function that returns unknown or a validated type.
  • Boolean and optional soup: { loading?: boolean, error?: string, data?: T } allows eight combinations, four of them impossible. Discriminated union.
  • Stringly-typed options: variant: string, event: string. Literal unions or template literal types.
  • Over-generic helpers: a deepMerge<T, U, V, W> with conditional types nobody can read, or a component with <T, K extends keyof T, F extends (x: T[K]) => unknown> for one caller. Simplify or specialise.
  • Type and runtime drift: an interface hand-written next to a schema, or props typed one way and validated another. Derive one from the other.
  • Function, object, {} as types: Function accepts anything callable and disables call checking; {} means "any non-nullish". Use precise signatures, Record<string, unknown>, or unknown.
  • Optional everywhere in DTOs because "the API might not send it": encode the real contract, and make the boundary validation enforce it.
  • Slow types: enormous unions, deeply recursive conditional types, template literal permutations in app code; symptoms are a sluggish editor and a tsc that takes minutes. Diagnose with --extendedDiagnostics/--generateTrace, move complexity into libraries or replace with runtime code.
  • @ts-ignore instead of @ts-expect-error: the former hides the error forever, the latter fails when the error disappears.

How it works

A review exercise: the same module before and after.

before.ts
export async function loadUser(id: any) {
  const res: any = await $fetch('/api/users/' + id)
  const user = res.data as { name: string, email: string, role: string }
  if (user.role == 'admin' || user.role == 'editor') {
    localStorage.setItem('user', JSON.stringify(user))
  }
  return { loading: false, error: undefined, data: user, isAdmin: user.role === 'admin' }
}

export function greet(user: any) {
  return 'Hello ' + user!.name.toUpperCase()
}
after.ts
import { z } from 'zod'

const ROLE = { admin: 'admin', editor: 'editor', viewer: 'viewer' } as const
type Role = typeof ROLE[keyof typeof ROLE]
const userSchema = z.object({ name: z.string(), email: z.string().email(), role: z.enum([ROLE.admin, ROLE.editor, ROLE.viewer]) })
type User = z.infer<typeof userSchema>
type UserId = string & { readonly __brand: 'UserId' }

type LoadState = { status: 'loading' } | { status: 'error', message: string } | { status: 'success', user: User }

export async function loadUser(id: UserId): Promise<LoadState> {
  try {
    const raw: unknown = await $fetch(`/api/users/${id}`) // template literal keeps the route type; `unknown` is honest for the demo
    const user = userSchema.parse(raw)                     // validated once, at the boundary
    if (canEdit(user.role)) persistUser(user)
    return { status: 'success', user }
  }
  catch (e) {
    return { status: 'error', message: e instanceof Error ? e.message : 'Unknown error' }
  }
}

const EDITOR_ROLES = new Set<Role>([ROLE.admin, ROLE.editor])
const canEdit = (role: Role) => EDITOR_ROLES.has(role)

export function greet(user: Pick<User, 'name'>): string {
  return `Hello ${user.name.toUpperCase()}`
}

Type-level tests that guard the contract:

test/types/user.test-d.ts
import { expectTypeOf } from 'vitest'
import type { LoadState } from '~/utils/user'

expectTypeOf<Extract<LoadState, { status: 'success' }>['user']['role']>().toEqualTypeOf<'admin' | 'editor' | 'viewer'>()

// @ts-expect-error a plain string is not a UserId: the brand is enforced
loadUser('123')
Gotcha· Fixing the type instead of the code

When the compiler complains, the quickest fix is often a cast or a !, and it is usually wrong: the complaint is telling you about a runtime path you have not handled. Treat every cast in a diff as a question in review: "what makes this true?"

Gotcha· Type tests that always pass

expectTypeOf(x).toMatchTypeOf<Foo>() passes for anything assignable to Foo, including wider types; toEqualTypeOf is the strict check. And // @ts-expect-error without a following expression is itself an error, which is the point.

Exercise

Exercise
  • Run grep -rn " as \| as any\|!\." src on a project and classify each hit: boundary cast, laziness, or genuinely unavoidable. Fix the laziness ones.
  • Enable @typescript-eslint/strict-type-checked on one directory and fix its findings; note which rules found real bugs.
  • Write a type test for a component's props with expectTypeOf and a @ts-expect-error for a wrong prop; make both run in CI.

Be able to say

Be able to say· What do you look for when reviewing TypeScript?

"Places where the type and the runtime can disagree: as casts on data the code did not construct, non-null assertions that silence a real null path, any leaking out of a helper or a catch, and DTO interfaces written by hand next to a schema. Then modelling: booleans and optionals that allow impossible states where a discriminated union would not, string where a literal union documents the options, Function or {} as types. Then proportion: a generic helper with four type parameters for one caller, or a recursive conditional type in application code that slows the editor. My fixes are boring on purpose: guards and schemas at boundaries, derive types from values, keep public signatures explicit, and let the strict lint rules enforce it so review does not have to."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.