Frontend Engineer Hub

TypeScript and Type Systems for Frontend Engineers (2026)

In short

TypeScript fluency is non-negotiable at FAANG-tier and SaaS-tier frontend hiring in 2026. The senior bar includes idiomatic discriminated unions for state machines, mapped and conditional types for type derivation, the satisfies operator for typed-but-narrow object literals, generic constraints with infer for advanced library APIs, type predicates for safe type narrowing, and branded types for nominal-typing simulation. The 2024 Stack Overflow Developer Survey shows TypeScript leading among professional web developers; FAANG-tier and SaaS-tier React shops ship near-100% TypeScript codebases.

Key takeaways

  • Discriminated unions are the canonical pattern for modeling finite state machines (loading / success / error) in a way that makes invalid states unreachable. Senior frontend engineers reach for them before adding ad-hoc booleans.
  • Mapped types and conditional types let you derive types from other types — Partial, Required, Pick, Record are built-ins; you write your own for repository-specific patterns.
  • The satisfies operator (TypeScript 4.9+) types an object literal narrowly while validating it conforms to a wider type. The pattern eliminates 'as const' patterns plus retains literal-type inference.
  • Generic constraints (extends), the infer keyword, and conditional types form the toolkit for typed library APIs. Matt Pocock's Total TypeScript content (mattpocock.com / Total TypeScript course) is the canonical reference.
  • Type predicates (function user is User pattern) narrow types in conditional branches. They are the safe alternative to type assertions when validating runtime data shape.
  • Branded types (a.k.a. opaque types) simulate nominal typing in TypeScript's structural type system — useful for distinguishing strings that are semantically different (UserId vs PostId) but structurally identical.
  • TypeScript fluency at senior+ in 2026 includes the unknown type at error-handling boundaries (catch (error: unknown)), the satisfies operator at config / route / schema boundaries, and the never type at exhaustiveness checking.

Discriminated unions for state machines

Discriminated unions are the canonical TypeScript pattern for modeling finite-state machines. The pattern: each state has a literal-typed discriminator (commonly named status or kind) plus state-specific fields. The compiler narrows the type inside switch / if blocks based on the discriminator.

type AsyncResult<T, E = Error> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: E };

function handleResult<T, E>(result: AsyncResult<T, E>): string {
  switch (result.status) {
    case "idle":
      return "Not started";
    case "loading":
      return "Loading\u2026";
    case "success":
      // result is narrowed to { status: "success"; data: T } here.
      return \`Got: ${JSON.stringify(result.data)}\`;
    case "error":
      // result is narrowed to { status: "error"; error: E } here.
      return \`Error: ${result.error}\`;
    default: {
      // Exhaustiveness check — result is "never" here. If a new state
      // is added to the union without handling, TypeScript flags this.
      const _exhaustive: never = result;
      throw new Error(\`Unhandled state: ${_exhaustive}\`);
    }
  }
}

What this pattern gets right: invalid states are unreachable (you cannot have a data field while status === "loading"); the compiler narrows the type inside each switch case, so you get type-safe access to state-specific fields; the never default branch enforces exhaustiveness — if you add a new state to the union without handling it, TypeScript errors.

The anti-pattern this replaces: type AsyncResult<T> = { data?: T; loading: boolean; error?: Error }. The anti-pattern allows { data: undefined, loading: true, error: someError } simultaneously — an invalid state that runtime code must defensively check. The discriminated union makes this state un-representable.

Mapped types and conditional types

Mapped types iterate over the keys of a type and transform each key. Conditional types branch on whether one type extends another. Together they form the toolkit for type-level programming in TypeScript.

// Built-in mapped types you should know:
//   Partial<T>     — make every property optional
//   Required<T>    — make every property required
//   Readonly<T>    — make every property readonly
//   Pick<T, K>    — pick a subset of properties
//   Omit<T, K>    — omit a subset of properties
//   Record<K, T>  — object with K keys, T values

type User = {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
};

// PublicUser strips passwordHash without manually reauthoring the type.
type PublicUser = Omit<User, "passwordHash">;
//   { id: string; name: string; email: string }

// A custom mapped type — make every value type a Promise.
type AsyncFields<T> = {
  [K in keyof T]: Promise<T[K]>;
};

type AsyncUser = AsyncFields<User>;
//   {
//     id: Promise<string>;
//     name: Promise<string>;
//     email: Promise<string>;
//     passwordHash: Promise<string>;
//   }

// A conditional type — extract the resolved type from a Promise.
type Awaited<T> = T extends Promise<infer U> ? U : T;

type X = Awaited<Promise<number>>;  // number
type Y = Awaited<number>;            // number

// A more complex conditional — derive component prop types from a config map.
type ComponentConfig = {
  Button: { variant: "primary" | "secondary"; onClick: () => void };
  Input: { type: "text" | "email"; value: string };
  Modal: { isOpen: boolean; onClose: () => void };
};

type ComponentProps<K extends keyof ComponentConfig> = ComponentConfig[K];

type ButtonProps = ComponentProps<"Button">;
//   { variant: "primary" | "secondary"; onClick: () => void }

The senior+ pattern: derive types instead of duplicating them. If you find yourself manually authoring a type that's a transformation of another type, reach for mapped or conditional types instead. Matt Pocock's Total TypeScript course (totaltypescript.com) is the canonical practical reference.

The satisfies operator

The satisfies operator (TypeScript 4.9+) lets you check that a value conforms to a wider type while preserving the value's narrower inferred type. The pattern eliminates many "as const" / explicit-cast patterns and is increasingly common in modern TypeScript codebases.

The problem satisfies solves:

type RouteConfig = Record<string, { path: string; auth: boolean }>;

// Without satisfies — you must annotate the type to validate it,
// but you LOSE the literal type inference (the keys become string).
const routes: RouteConfig = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
  settings: { path: "/settings", auth: true },
};

routes.home;          // OK — but type is { path: string; auth: boolean }
routes.unknownRoute;  // OK — TypeScript thinks any string key works.

// With satisfies — TypeScript validates the value matches RouteConfig
// AND preserves the literal type (so keys are narrowed to "home" | "dashboard" | "settings").
const routesWithSatisfies = {
  home: { path: "/", auth: false },
  dashboard: { path: "/dashboard", auth: true },
  settings: { path: "/settings", auth: true },
} satisfies RouteConfig;

routesWithSatisfies.home;             // OK
routesWithSatisfies.unknownRoute;     // ERROR — Property does not exist
routesWithSatisfies.home.auth;        // type is false (literal!) not boolean

The senior pattern: use satisfies for config objects, schema definitions, route maps, and any object where you want both validation against a wider type and preservation of the narrow literal types. The TypeScript 4.9 release notes (typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html) document the operator.

Generic constraints and type predicates

Two patterns that show up in senior+ TypeScript code: generic constraints (the extends keyword in generic parameters) and type predicates (the x is T return-type syntax for narrowing functions).

Generic constraints — restrict the shape a generic type parameter must satisfy:

// A generic function that requires T to have an "id" field.
function findById<T extends { id: string }>(
  items: readonly T[],
  id: string,
): T | undefined {
  return items.find((item) => item.id === id);
}

// A generic that uses keyof to constrain a key parameter.
function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: "u1", name: "Ada", email: "[email protected]" };
const name = getValue(user, "name");      // type is string
const id = getValue(user, "id");          // type is string
const bad = getValue(user, "unknownKey"); // ERROR

Type predicates — narrow a runtime type check into a TypeScript-known narrowing:

type ApiResponse =
  | { kind: "success"; data: unknown }
  | { kind: "error"; error: string };

function isSuccess(
  response: ApiResponse,
): response is { kind: "success"; data: unknown } {
  return response.kind === "success";
}

function handleResponse(response: ApiResponse) {
  if (isSuccess(response)) {
    // response is narrowed to { kind: "success"; data: unknown }
    console.log(response.data);
  } else {
    // response is narrowed to { kind: "error"; error: string }
    console.error(response.error);
  }
}

// Type predicate for runtime data validation:
type User = { id: string; name: string; email: string };

function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "id" in value &&
    "name" in value &&
    "email" in value &&
    typeof (value as Record<string, unknown>).id === "string" &&
    typeof (value as Record<string, unknown>).name === "string" &&
    typeof (value as Record<string, unknown>).email === "string"
  );
}

function parseUserData(raw: unknown): User | null {
  return isUser(raw) ? raw : null;
}

The senior+ pattern: use type predicates for data-validation boundaries (API responses, localStorage reads, postMessage handlers). For complex schema validation, prefer zod (zod.dev) which produces type predicates as a side effect of schema definition. The TypeScript handbook (typescriptlang.org/docs/handbook/2/narrowing.html) covers narrowing patterns.

Frequently asked questions

Should I use 'as const' or 'satisfies'?
Both, for different jobs. 'as const' makes a value's type fully read-only and literal. 'satisfies' validates the value matches a wider type while preserving the inferred (narrow) type. Use 'as const' for arrays / tuples where you want the values literal-typed; use 'satisfies' for objects you're validating against a Record / interface but want to preserve key narrowing.
Should I prefer interfaces or types?
Both work; the codebase consistency matters more than the choice. Common pattern in modern frontend codebases: use 'type' for everything because of better support for unions, intersections, and computed types. The Microsoft TypeScript team has historically recommended interfaces for object types and types for unions / aliases, but in 2026 'type' for both is increasingly common. Matt Pocock's writing covers the trade-offs.
How do I handle errors typed as 'unknown' in catch blocks?
TypeScript 4.4+ defaults catch parameters to 'unknown'. Narrow the unknown using instanceof, type predicate, or zod schema before accessing properties. The pattern: catch (error: unknown) { if (error instanceof Error) { ... } else { ... } }. Don't widen back to 'any' — that defeats the safety upgrade.
Should I use namespaces?
Rarely. ES modules are the dominant module system in 2026 frontend. Namespaces (the legacy 'namespace X {}' syntax) are uncommon outside legacy code or .d.ts ambient declarations. The Microsoft TypeScript team explicitly recommends ES modules for new code.
How do I type a third-party library that doesn't ship types?
Three options: install the @types/library-name package from DefinitelyTyped if it exists; write a local 'X.d.ts' module declaration in your project; declare the module as 'any' if there's no realistic alternative. The DefinitelyTyped repo (github.com/DefinitelyTyped/DefinitelyTyped) is the canonical resource.
What's the difference between 'unknown' and 'any'?
'any' opts out of type checking — every operation is allowed; the compiler doesn't help you. 'unknown' is type-safe — you must narrow the type before accessing properties. The senior+ rule: never use 'any' except in deeply typed library code where you have no choice; reach for 'unknown' as the safe equivalent.
Should I use TypeScript's strict mode?
Yes. The strict mode (strict: true in tsconfig) enables strictNullChecks, noImplicitAny, strictFunctionTypes, and other safety flags. Modern frontend codebases run with strict mode by default; turning it off is the deviation that requires justification. The TypeScript handbook (typescriptlang.org/tsconfig) covers each strict-mode flag in detail.

Sources

  1. TypeScript Handbook — the official language reference. Canonical for type-system fundamentals.
  2. Matt Pocock — Total TypeScript. Canonical practical TypeScript course at senior+ depth.
  3. TypeScript 4.9 Release Notes — the satisfies operator introduction.
  4. TypeScript Handbook — Narrowing. Canonical for type predicates and discriminated unions.
  5. Zod — TypeScript-first schema validation. Modern data-validation boundary tool.
  6. DefinitelyTyped — the canonical community types repository.
  7. Stack Overflow Developer Survey 2024 — TypeScript adoption among professional developers.

About the author. Blake Crosley founded ResumeGeni and writes about frontend engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.