TypeScript in practice

Type system fundamentals

Structural typing, unknown versus any versus never, unions and intersections, literal types and as const, narrowing and type guards, satisfies, readonly and tuples, enums versus unions, and the strictness flags that matter.

The fundamentals interviewers test are the ones that prevent bugs: knowing that TypeScript is structural, that any disables checking for everything it touches, how narrowing works and where it fails, and why satisfies exists. This page is the checklist.

Know

  • Structural typing: compatibility is by shape, not by name. Any object with the right properties is a Point; extra properties are allowed except in fresh object literals (excess property checks). Nominal behaviour needs branded types (type UserId = string & { __brand: 'UserId' }).
  • any versus unknown versus never: any opts out of checking and spreads through every expression it touches; unknown is the safe top type that must be narrowed before use (the right type for JSON.parse, catch (e), external data); never is the empty type: functions that throw, unreachable branches, and the tool for exhaustiveness checks.
  • Unions and intersections: A | B is either; A & B is both (conflicting property types intersect to never). Discriminated unions share a literal kind/type/status property and are the idiom for state machines, API results and component variants.
  • Literal types and as const: 'sm' | 'md' | 'lg' instead of string; as const freezes a value to its literal, readonly type so typeof SIZES[number] derives the union from the array. Template literal types build strings from unions (`${Size}-${Color}`).
  • Narrowing: typeof, instanceof, in, equality, truthiness, discriminant checks, Array.isArray, user-defined type guards ((x): x is Foo), assertion functions (asserts x is Foo), and since TS 5.5 inferred type predicates from simple filter callbacks. Control flow analysis narrows let variables but not properties across function calls, and not inside callbacks executed later.
  • satisfies (4.9) checks a value against a type without widening it: const routes = {...} satisfies Record<string, Route> keeps the literal keys for autocomplete while validating the shape. Use it for config objects and lookup tables instead of a type annotation.
  • readonly, tuples, const type parameters: readonly T[]/ReadonlyArray, Readonly<T>, tuple types with optional and rest elements, labelled tuples for defineEmits, const T type parameters (5.0) to infer literals in generic calls.
  • Enums versus unions: enum emits runtime code, has surprising numeric semantics and cannot be erased by Node's type stripping; string literal unions plus an as const object give the same ergonomics with zero runtime and full inference. erasableSyntaxOnly (5.8) forbids enums, namespaces and parameter properties so .ts runs natively in Node 24.
  • Optional versus undefined: prop?: T means "may be absent"; with exactOptionalPropertyTypes you cannot assign undefined explicitly. noUncheckedIndexedAccess makes arr[i] and record[key] T | undefined, which catches the most common runtime crash class.
  • Strictness: strict bundles strictNullChecks, noImplicitAny, strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables and more; add noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch. TypeScript 7 defaults strict to on.

How it works

shared/types/order.ts
// Illegal states unrepresentable: a discriminated union instead of { loading, error, data } booleans.
export type OrderState =
  | { status: 'idle' }
  | { status: 'loading', startedAt: number }
  | { status: 'success', order: Order }
  | { status: 'error', error: { status: number, message: string } }

export const ORDER_STATUSES = ['draft', 'paid', 'shipped', 'cancelled'] as const
export type OrderStatus = typeof ORDER_STATUSES[number] // 'draft' | 'paid' | 'shipped' | 'cancelled'

// Branded ids: an OrderId cannot be passed where a UserId is expected, although both are strings at runtime.
export type OrderId = string & { readonly __brand: 'OrderId' }
export const asOrderId = (raw: string): OrderId => raw as OrderId // the one sanctioned cast, at the boundary

// Exhaustiveness: adding a status without handling it fails to compile.
export function describe(state: OrderState): string {
  switch (state.status) {
    case 'idle': return 'Nothing loaded'
    case 'loading': return `Loading since ${state.startedAt}`
    case 'success': return `Order ${state.order.id}`
    case 'error': return `Failed with ${state.error.status}`
    default: {
      const exhaustive: never = state
      return exhaustive
    }
  }
}

Narrowing that works and narrowing that does not:

narrowing.ts
function isOrder(value: unknown): value is Order {
  return typeof value === 'object' && value !== null && 'id' in value && 'status' in value
}

const raw: unknown = JSON.parse(text)
if (isOrder(raw)) console.log(raw.status)   // narrowed

const maybe: string | undefined = getName()
if (maybe) {
  setTimeout(() => maybe.toUpperCase(), 0)  // ok: `const` cannot change
}

const box = { value: undefined as string | undefined }
if (box.value) {
  refresh()                                   // could reassign box.value
  box.value.toUpperCase()                     // TS still narrows here, but it is unsound after a call: copy to a const first
}

// satisfies keeps literal keys while checking the shape
const routes = {
  home: { path: '/', auth: false },
  account: { path: '/account', auth: true },
} satisfies Record<string, { path: string, auth: boolean }>
routes.account.path // autocomplete for `home` | `account`, unlike an annotation which would widen to string keys
Gotcha· `as` is not a check

const user = data as User tells the compiler to trust you; the runtime does nothing. Casting API data, event.target, or JSON.parse output is where TypeScript's guarantees end. Use a type guard, a schema, or satisfies when the value is a literal you control.

Gotcha· Narrowing across callbacks

A variable narrowed with if (x) is not narrowed inside a callback that runs later if it is a let or a property, because it might change before the callback runs. Copy to a const inside the check.

Exercise

Exercise
  • Rewrite a component state with three booleans (loading, error, data) as a discriminated union and count the impossible combinations that disappear.
  • Turn on noUncheckedIndexedAccess in a small project and fix the errors; note how many were real potential crashes.
  • Replace an enum with an as const object plus a union type and check that Node runs the file directly with type stripping.

Be able to say

Be able to say· What is the difference between any, unknown and never, and when do you use each?

"any turns off type checking for everything the value flows into, so a single any from a fetch can silently untype a whole module; I avoid it except at deliberately unchecked seams. unknown is the honest top type: I can hold anything but must narrow with a guard or a schema before using it, which is exactly right for JSON, catch clauses and external inputs. never is the empty type: the return type of functions that throw, the type of an exhausted union in a switch default, which is how I make the compiler fail when someone adds a variant and forgets to handle it."

Be able to say· Why do you prefer literal unions and satisfies over enums and annotations?

"A literal union such as 'sm' | 'md' | 'lg' costs nothing at runtime, infers from an as const array so the values exist once, erases cleanly for Node's native type stripping, and reads as documentation at every call site; enums emit code and have numeric quirks. satisfies solves a different problem: annotating a config object as Record<string, Route> widens the keys and loses autocomplete, while satisfies validates the shape and keeps the literal type, so routes.account is still known to exist. Both are about keeping information the compiler already has."

Your notes

Open in Notes

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