Chapter 15 of 24 · TypeScript for Beginners — Complete Free Tutorial

Conditional Types and infer

So far a type transform has always produced the same shape for every input. Real type-level code needs decisions: if the argument is an array, unwrap it; otherwise keep it. Chapter 15 of this 24-chapter series teaches conditional types — T extends U ? X : Y — plus infer, the keyword that lets one type reach inside another and pull a hidden type out.

The shape of a conditional type

The syntax is the ternary operator you already know, moved into the type world. The compiler checks whether T is assignable to U and resolves to X when it is and Y when it is not.

type IsString<T> = T extends string ? 'yes' : 'no';

type A = IsString<'hello'>;   // 'yes'
type B = IsString<number>;    // 'no'

type Flattened<T> = T extends readonly unknown[] ? T[number] : T;
type C = Flattened<string[]>;  // string
type D = Flattened<number>;    // number

Expected output:

A  'yes'
B  'no'
C  string
D  number

Two behaviours to file away immediately. First, when the checked type is a naked type parameter (plain T, not [T]), resolution can be deferred: inside a generic function the conditional stays unexpanded until someone supplies a concrete T. Second, that same naked parameter makes the conditional distribute over unions.

Distribution: one union member at a time

A distributing conditional applies to each member of a union separately and unions the results. It is usually what you want — and occasionally a trap when you meant to check the union as a whole.

type Distributes<T> = T extends string ? 'text' : 'other';
type Stays<T> = [T] extends [string] ? 'text' : 'other';

type D1 = Distributes<string | number>;  // 'text' | 'other'
type S1 = Stays<string | number>;        // 'other'

type Merged = Distributes<string> | Distributes<number>; // 'text' | 'other'

Expected output:

D1  'text' | 'other'   // string got 'text', number got 'other'
S1  'other'            // the whole union fails the check, so one branch

Wrapping the check in a one-tuple — [T] extends [U] — hides T from the distributive rule and forces a single decision about the entire union. Reach for it whenever you need “is every member a string?” instead of “which members are strings?”.

infer: capturing a hidden type

infer declares a new type variable inside an extends clause. You are telling the compiler: “if T has this shape, hand me the piece that fits here.” It is how ReturnType, Parameters and Awaited are written, and you will use it whenever a type needs to reach inside another type.

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
type ElementOf<T> = T extends readonly (infer E)[] ? E : never;
type UnwrapPromise<T> = T extends Promise<infer U> ? UnwrapPromise<U> : T;

type R1 = MyReturnType<() => boolean>;
type P1 = MyParameters<(a: string, b?: number) => boolean>;
type E1 = ElementOf<string[]>;
type E2 = ElementOf<readonly number[]>;
type U1 = UnwrapPromise<Promise<Promise<boolean>>>;

Expected output:

R1  boolean
P1  [a: string, b?: number]
E1  string
E2  number
U1  boolean        // recursion peels every layer

Notice the pattern in each line: match a shape, place infer where the interesting type lives, and return it. UnwrapPromise also shows recursion — it calls itself so nested promises fully collapse, exactly like the built-in Awaited<T> from chapter 13.

never and the built-in filters

never is the bottom type: it is assignable to every other type, and nothing populates it. In conditional types it plays a third role — the value of an impossible branch. Union members that fail a filter become never and silently vanish from the result, which is precisely how Exclude and Extract work:

// Straight from the standard library:
type Exclude<T, U> = T extends U ? never : T;
type Extract<T, U> = T extends U ? T : never;

type Working = Exclude<'draft' | 'review' | 'published', 'published'>;
type Live = Extract<'draft' | 'review' | 'published', 'published'>;
type Nothing = Extract<string, number>;   // never: nothing matched
type Untouched = Exclude<string, number>; // string: nothing was removed

Expected output:

Working    'draft' | 'review'
Live       'published'
Nothing    never
Untouched  string

One classic gotcha follows from distribution: never is an empty union, so a distributing conditional over never applies zero times and the whole type collapses to never — IsString<never> is never, not 'no'. When you must test for never, write [T] extends [never] so nothing distributes.

Worked example: DeepReadonly

Combining distribution, a base case and recursion produces a genuinely useful utility: Readonly, but all the way down. Arrays become readonly arrays, objects become readonly objects, and primitives fall through untouched.

type DeepReadonly<T> = T extends readonly (infer E)[]
  ? readonly DeepReadonly<E>[]
  : T extends object
    ? { readonly [K in keyof T]: DeepReadonly<T[K]> }
    : T;

interface TreeNode {
  id: string;
  children: TreeNode[];
  meta: { tag: string; weights: number[] };
}

type FrozenNode = DeepReadonly<TreeNode>;

declare const node: FrozenNode;
node.meta.tag = 'x';                 // compile error
console.log(node.meta.tag, node.children.length);

Expected output:

FrozenNode = {
  readonly id: string;
  readonly children: readonly FrozenNode[];
  readonly meta: { readonly tag: string; readonly weights: readonly number[] };
}
error TS2540: Cannot assign to 'tag' because it is a read-only property.

Read the branches top to bottom: arrays first (so children and weights match), then any other object (so meta recurses), then the base case where primitives return as-is — without that last : T the recursion would never stop. That stop is not decoration: recursive conditionals without a proper base case (or over types that contain themselves through an odd path) can make the compiler report error TS2589: Type instantiation is excessively deep and possibly infinite. When you see TS2589, look for a missing base case or an unbounded recursion first.

Common mistakes

  • Forgetting distribution — you checked T extends string expecting one answer for string | number and got 'text' | 'other'; wrap it as [T] extends [string] for a single decision.
  • Testing never with a naked parameter — T extends never ? A : B distributes over an empty union and yields never; use a tuple to test for it.
  • Expecting a deferred conditional to resolve early — inside a generic, IsString<T> stays unexpanded until T is concrete, so it cannot be assigned to 'yes' | 'no' yet.
  • Recursive types without a base case — the compiler eventually gives up with TS2589; return T unchanged for the types that should stop the recursion.
  • Losing union members by accident — writing T extends X ? Thing : never silently deletes every non-matching member; sometimes that is the feature (Exclude), sometimes it is a bug.

Practice checklist

  1. Write IsString<T>, apply it to 'hi' | number, and predict the result before hovering.
  2. Write your own MyReturnType<T> with infer and compare it against the built-in ReturnType on three functions.
  3. Unwrap string[] and readonly boolean[] with an ElementOf type of your own.
  4. Build DeepPartial<T> recursively, apply it to a two-level object, and confirm the nested fields became optional.
  5. Trigger TS2589 on purpose with a recursive type that has no base case, read the message, then add the base case that fixes it.

What's next?

You now hold every generic tool TypeScript offers. The next chapter puts them together on real problems — API clients, event emitters and repositories — in Generics in Practice.

Quiz: Conditional Types and infer

5 questions · 70% to pass

Frequently Asked Questions

TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.

You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.

No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.

If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.

Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.

strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.

any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.

Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.

Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.

Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.