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
- TypeScript Handbook — the official language reference. Canonical for type-system fundamentals.
- Matt Pocock — Total TypeScript. Canonical practical TypeScript course at senior+ depth.
- TypeScript 4.9 Release Notes — the satisfies operator introduction.
- TypeScript Handbook — Narrowing. Canonical for type predicates and discriminated unions.
- Zod — TypeScript-first schema validation. Modern data-validation boundary tool.
- DefinitelyTyped — the canonical community types repository.
- 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.