TypeScript Advanced Types in Practice: From Conditional Types to Type-Level Programming

前端工程

The refactor that changed how I see types

Last year we renamed a field in an internal admin project: avatarUrl became avatar on userProfile. It looked like a safe change — until TypeScript lit up 31 files with errors. We thought we were just changing a name; instead the compiler flagged every place that concatenated the URL with a CDN prefix, added a null fallback, or passed it to <img src>.

Had this been JavaScript, those errors would have surfaced at runtime — possibly in the user's browser. That night taught me one thing: types aren't a tax; the compiler is doing free code review for you.

This article skips basic syntax. I assume you're comfortable with interface and generics. We go straight into the advanced types that look scary but are hard to give up once mastered.


1. Lay the foundation: keyof and index access

Most advanced patterns rest on two primitives.

interface User {
  id: number;
  name: string;
  email: string;
}

type UserKeys = keyof User; // "id" | "name" | "email"

type IdType = User["id"];   // number
type NameOrEmail = User["name" | "email"]; // string

keyof T gives you a union of all keys; T[K] indexes into the type. Combine them and you get helpers that "pick a value type by key":

function pick<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user: User = { id: 1, name: "Ada", email: "a@x.com" };
const name = pick(user, "name"); // inferred as string

Notice the second generic K extends keyof T — that constraint guarantees the caller can only pass a key that actually exists on the object.

typeof on a value

Used in a type position, typeof derives a type from a value. It's perfect for extracting types from runtime code so you don't declare things twice:

const config = {
  apiBase: "https://api.example.com",
  timeout: 8000,
  retries: 3,
};

type Config = typeof config;
// { apiBase: string; timeout: number; retries: number }

2. Conditional types: the ternary of types

The shape is T extends U ? X : Y — read it as "if T is assignable to U, use X, else Y".

type IsArray<T> = T extends unknown[] ? true : false;

type A = IsArray<string[]>; // true
type B = IsArray<string>;   // false

Extract and Exclude from the standard library are written with conditional types. Implementing them yourself sticks better than reading docs ten times:

type MyExtract<T, U> = T extends U ? T : never;
type MyExclude<T, U> = T extends U ? never : T;

type T0 = MyExtract<"a" | "b" | "c", "a" | "c">; // "a" | "c"
type T1 = MyExclude<"a" | "b" | "c", "a" | "c">; // "b"

Key insight: when T is a union, the conditional type is evaluated per member, then the results are unioned. That's exactly why MyExclude can drop members one by one.

infer: pulling a piece out of a type

infer is only allowed inside an extends branch; it means "don't hardcode this — let the compiler figure it out." It gives types a "destructure" ability.

type First<T extends unknown[]> = T extends [infer Head, ...unknown[]]
  ? Head
  : never;

type F = First<[string, number, boolean]>; // string

type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;

function loadUser() {
  return { id: 1, name: "Ada" };
}
type Loaded = MyReturnType<typeof loadUser>; // { id: number; name: string }

infer can sit at the return position, in parameters, inside a Promise, in array elements — anywhere. Awaited<T> peels Promise layers using it:

type MyAwaited<T> = T extends Promise<infer U>
  ? U extends Promise<unknown>
    ? MyAwaited<U>
    : U
  : T;

type R = MyAwaited<Promise<Promise<number>>>; // number

3. Mapped types and key remapping

A mapped type "iterates over all keys of a type," like a map over an object.

type PartialUser = {
  [K in keyof User]?: User[K];
};
// { id?: number; name?: string; email?: string }

The +/- modifiers add or remove readonly and ?:

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

key remapping (TypeScript 4.1+)

The as clause rewrites keys during mapping — the trick behind getter/event transforms:

type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};

type UserGetters = Getters<User>;
// {
//   getId: () => number;
//   getName: () => string;
//   getEmail: () => string;
// }

Combined with Exclude, you can also filter keys:

type MethodsOnly<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K];
};

When the as result is never, that key is dropped. Handy for trimming types.


4. Template literal types

Template literal types concatenate string literals. They're great for constraining event names, routes, and CSS props:

type Lang = "zh" | "en" | "ja";
type Path = `/i18n/${Lang}`; // "/i18n/zh" | "/i18n/en" | "/i18n/ja"

type EventName = `on${Capitalize<"click" | "change" | "submit">}`;
// "onClick" | "onChange" | "onSubmit"

A practical, type-safe CSS class setter:

type Spacing = "sm" | "md" | "lg";
type MarginClass = `m-${Spacing}` | `mt-${Spacing}` | `mb-${Spacing`;

function cx(...classes: MarginClass[]) {
  return classes.join(" ");
}
cx("mt-md", "mb-lg"); // OK
// cx("padding-sm");   // error: not in the allowed set

This kind of constraint shines when building component libraries, DSLs, and config validators — spelling mistakes get caught at compile time.


5. Recursive types

Types can be recursive. With readonly and an object check you can express deeply immutable structures:

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

const cfg: DeepReadonly<User> = { id: 1, name: "Ada", email: "a@x.com" };
// cfg.name = "Bob"; // error: cannot assign to readonly property

A DeepPartial, useful for "draft forms" where the user fills in only some fields:

type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};

A JSON serialization type is also a recursion classic:

type Json =
  | string
  | number
  | boolean
  | null
  | Json[]
  | { [key: string]: Json };

6. Challenge: parse a query string at the type level

A famous type-gymnastics problem: turn "a=1&b=2&c=3" into { a: string; b: string; c: string }. The full implementation is long; here's a runnable, readable version. The idea: split on &, split on =, accumulate:

type Split<S extends string, D extends string> =
  string extends S ? string[]
  : S extends `${infer Head}${D}${infer Tail}`
    ? [Head, ...Split<Tail, D>]
    : [S];

type ParsePair<P extends string> =
  P extends `${infer K}=${infer V}` ? { [k in K]: V } : {};

type Merge<T> =
  (T extends any ? (k: T) => void : never) extends (k: infer U) => void
    ? U
    : never;

type ParseQueryString<S extends string> =
  Merge<ParsePair<Split<S, "&">[number]>>;

type Q = ParseQueryString<"a=1&b=2&c=3">;
// intersection of { a: "1" } & { b: "2" } & { c: "3" }, i.e. merged

The value of this exercise isn't "memorize the answer" — it forces you to chain infer, recursion, as remapping, and [number] index access together. Write it yourself before peeking at the solution.


7. The boundary between types and runtime

Types are erased after compilation, so "checks done in types" don't exist at runtime. Two common runtime partners:

Type guards

function isUser(v: unknown): v is User {
  return (
    typeof v === "object" &&
    v !== null &&
    "id" in v &&
    "name" in v
  );
}

satisfies: correct value, preserved literal type

satisfies (4.9) checks a value against a type without widening it:

const routes = {
  home: "/",
  user: "/users/:id",
} satisfies Record<string, string>;

type RouteName = keyof typeof routes; // "home" | "user" (not string)

Compared to as, satisfies validates but never rewrites — far safer.

Pairing with zod

When you need runtime validation too, libraries like zod let you derive the type back from the schema with z.infer, so one definition serves both sides:

import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

type User = z.infer<typeof UserSchema>; // always in sync with the schema

8. Performance pitfall: excessive instantiation depth

The more complex the type, the more work the compiler does. Deep recursion in conditional types can hit error TS2589: Type instantiation is excessively deep and possibly infinite.

How to ease it:

  • Prefer array indexing over very long tuple chains: T extends [infer H, ...infer R] is generally more stable than deeply nested ternaries.
  • Always give recursion a termination condition, and make sure each step "shrinks" the type.
  • Avoid pointless deep mapping; split into intermediate types so the compiler solves them step by step.
  • If you hit TS2589, first ask: am I reimplementing runtime logic in types? The type system isn't Turing-complete by design — forcing it to simulate loops rarely pays off.

9. Modeling API responses

Put it to use: wrap fetch in a type-safe client that distinguishes success/failure at the type level.

type ApiResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

async function getJson<T>(url: string): Promise<ApiResult<T>> {
  try {
    const res = await fetch(url);
    if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
    const data = (await res.json()) as T;
    return { ok: true, data };
  } catch (e) {
    return { ok: false, error: String(e) };
  }
}

interface Order { id: number; total: number }

const r = await getJson<Order>("/api/orders/1");
if (r.ok) {
  console.log(r.data.total); // safe after narrowing
} else {
  console.error(r.error);
}

Making "failure" an explicit branch instead of throwing means callers are forced to handle errors — far steadier than slinging any.


FAQ

Q1: Is type gymnastics worth the time?

If you write business CRUD, mastering conditional types, infer, mapped types, and template literals covers 90% of cases. The hard "gymnastics" problems mostly train your thinking. Don't obfuscate the team's code to show off — readability is part of a type too.

Q2: Is there still a difference between interface and type?

For daily use they're nearly interchangeable. Differences: interfaces support declaration merging (same name merges); type aliases support unions, conditionals, and mappings. My habit: interface for "object shapes," type for "type transforms."

Q3: Should everyone enable strict mode?

Strongly yes. strict: true flips on noImplicitAny, strictNullChecks, and more in one go. You'll fix extra errors short-term, but you'll eliminate a whole class of undefined is not a function long-term.

Q4: Is generic extends the same as inheritance?

No. T extends Foo means "T must be assignable to Foo" — it shares a name with OOP extends by coincidence. Reading it as "constrained to" avoids confusion.


When writing types and wrangling JSON, these ToolsKu tools save time:


The point of the type system isn't to silence the compiler, but to make it speak before you ship a mistake. Treat types as a colleague, not a warden.

Try these browser-local tools — no sign-up required →

#TypeScript#类型系统#类型体操#泛型#前端工程