Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 14 of 21 · ~3 min

Mapped Types and Conditional Types

Partial, Pick, Readonly, and the rest from the last chapter aren't special-cased into the compiler — they're ordinary mapped types, written in library code you could have written yourself:

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

The pattern generalizes to anything you want to derive field-by-field from an existing type. The queue's data-validation layer needs exactly one check function per field on Applicant, and writing that mapping by hand invites the two lists to quietly drift apart the moment someone adds a field to one and forgets the other:

type Validators<T> = {
  [K in keyof T]: (value: T[K]) => boolean;
};

const applicantValidators: Validators<Applicant> = {
  id: (v) => v > 0,
  name: (v) => v.trim().length > 0,
  email: (v) => v.includes("@"),
  stage: (v) => ["screening", "interview", "offer", "hired", "rejected"].includes(v),
  internalNotes: (v) => typeof v === "string",
};

Add a field to Applicant, and applicantValidators immediately fails to compile until you supply a matching function — with the parameter's type already inferred correctly from that new field. The object isn't hand-matched to Applicant; it's generated from it, so there's no gap for the two to drift apart in.

Mapped types can rename keys too, not just copy them, using an as clause paired with a template literal type:

type ChangeEvents<T> = {
  [K in keyof T as `${string & K}Changed`]: (value: T[K]) => void;
};

type ApplicantEvents = ChangeEvents<Applicant>;
// { idChanged: (value: number) => void; nameChanged: (value: string) => void; stageChanged: ...; ... }

const onStageChanged: ApplicantEvents["stageChanged"] = (newStage) => {
  console.log(`stage is now ${newStage}`);
};

Useful anywhere you're generating one handler per field programmatically — an audit log, say — rather than maintaining a hand-written list of event names next to a hand-written list of fields.

Conditional types let a type itself branch, the way an if lets a value branch:

type IsScored<T> = T extends { score: number } ? true : false;

type A = IsScored<{ score: 87 }>;         // true
type B = IsScored<{ status: "pending" }>; // false

Combine that with infer, and a conditional type can reach into a larger shape and pull one specific piece back out. The queue's export step hands back a batch — an array of applicants — and infer is how you write a type that names whatever's sitting inside that array, without knowing in advance what the array holds:

type BatchMember<T> = T extends (infer Item)[] ? Item : never;
type SingleApplicant = BatchMember<Applicant[]>; // Applicant

This is genuinely how something like ReturnType is implemented under the hood — a conditional type matching against a function shape, with infer grabbing the return position out of the match. The same trick unwraps a Promise, which matters the moment the queue starts fetching applicants over the network instead of holding them all in memory:

async function fetchApplicant(id: number): Promise<Applicant> {
  return findApplicant(id);
}

type Resolved<T> = T extends Promise<infer R> ? R : T;
type FetchedApplicant = Resolved<ReturnType<typeof fetchApplicant>>; // Applicant, not Promise<Applicant>