Gå till innehåll
·20 min läsning

Avancerade typer i TypeScript: Conditional, Mapped och Template Literal-magi

En djupdykning i TypeScripts mest kraftfulla typfunktioner — conditional types, mapped types, infer-nyckelordet och template literal types. Riktiga mönster du faktiskt kommer att använda.

Dela:X / TwitterLinkedIn

De flesta TypeScript-utvecklare planar ut på samma ställe. De kan interfaces, generics och union types. De kan typa en React-komponent. De kan få de röda snirkliga linjerna att försvinna. Men de behandlar typsystemet som ett nödvändigt ont — något som finns till för att förhindra buggar, inte något som aktivt hjälper dem att designa bättre mjukvara.

Jag satt fast på den platån i ungefär två år. Det som drog mig ur var insikten att TypeScripts typsystem är ett programmeringsspråk i sig. Det har villkor, loopar, mönstermatchning, rekursion och strängmanipulering. När du väl internaliserat det förändras allt. Du slutar kämpa mot kompilatorn och börjar samarbeta med den.

Det här inlägget täcker de typnivåfunktioner jag ständigt använder i produktionskod. Inga akademiska övningar — riktiga mönster som har räddat mig från riktiga buggar.

Tänkesättet för programmering på typnivå#

Innan vi dyker ner i syntax vill jag ändra hur du tänker kring typer.

I programmering på värdenivå skriver du funktioner som transformerar data vid körning:

typescript
function extractIds(users: User[]): number[] {
  return users.map(u => u.id);
}

I programmering på typnivå skriver du typer som transformerar andra typer vid kompilering:

typescript
type ExtractIds<T extends { id: unknown }[]> = T[number]["id"];

Den mentala modellen är densamma — input, transformation, output. Skillnaden är att typnivåkod körs under kompilering, inte vid körning. Den producerar noll JavaScript. Dess enda syfte är att göra omöjliga tillstånd orepresenterbara.

Om du någonsin skrivit en funktion där tre av fyra parametrar bara är meningsfulla i vissa kombinationer, eller där returtypen beror på vad du skickar in, har du redan behövt programmering på typnivå. Du visste kanske bara inte att TypeScript kunde uttrycka det.

Conditional Types#

Conditional types är typsystemets if/else. Syntaxen ser ut som en ternäroperator:

typescript
type IsString<T> = T extends string ? true : false;
 
type A = IsString<"hello">; // true
type B = IsString<42>;      // false
type C = IsString<string>;  // true

Nyckelordet extends här betyder inte arv. Det betyder "är tilldelningsbart till." Tänk på det som "passar T in i formen av string?"

Bygg dina egna utility types#

Låt oss bygga om några av TypeScripts inbyggda utility types för att förstå hur de fungerar.

NonNullable — tar bort null och undefined från en union:

typescript
type MyNonNullable<T> = T extends null | undefined ? never : T;
 
type Test = MyNonNullable<string | null | undefined>;
// Resultat: string

Typen never är den "tomma mängden" — den tar bort något från en union. När en gren av en conditional type löser sig till never försvinner helt enkelt den medlemmen ur unionen.

Extract — behåller bara unionmedlemmar som matchar en begränsning:

typescript
type MyExtract<T, U> = T extends U ? T : never;
 
type Numbers = Extract<string | number | boolean, number | boolean>;
// Resultat: number | boolean

Exclude — motsatsen, tar bort matchande medlemmar:

typescript
type MyExclude<T, U> = T extends U ? never : T;
 
type WithoutStrings = Exclude<string | number | boolean, string>;
// Resultat: number | boolean

Distributive Conditional Types#

Här blir det intressant, och det är här de flesta blir förvirrade. När du skickar en union type till en conditional type distribuerar TypeScript villkoret över varje medlem i unionen individuellt.

typescript
type ToArray<T> = T extends unknown ? T[] : never;
 
type Result = ToArray<string | number>;
// Du kanske förväntar dig: (string | number)[]
// Faktiskt resultat:    string[] | number[]

TypeScript evaluerar inte (string | number) extends unknown. Istället evaluerar den string extends unknown och number extends unknown separat, och sedan unionerar den resultaten.

Det är därför Extract och Exclude fungerar som de gör. Distributionen sker automatiskt när T är en "naken" typparameter (inte inlindad i något).

Om du vill förhindra distribution, linda båda sidorna i en tupel:

typescript
type ToArrayNonDist<T> = [T] extends [unknown] ? T[] : never;
 
type Result = ToArrayNonDist<string | number>;
// Resultat: (string | number)[]

Jag har blivit biten av detta fler gånger än jag vill erkänna. Om din conditional type ger oväntade resultat med union-input är distribution nästan alltid orsaken.

Praktiskt exempel: API-responshanterare#

Här är ett mönster jag använder när jag hanterar API-svar som antingen kan lyckas eller misslyckas:

typescript
type ApiResponse<T> =
  | { status: "success"; data: T; error: never }
  | { status: "error"; data: never; error: string };
 
type ExtractData<R> = R extends { status: "success"; data: infer D } ? D : never;
type ExtractError<R> = R extends { status: "error"; error: infer E } ? E : never;
 
type UserResponse = ApiResponse<{ id: number; name: string }>;
 
type UserData = ExtractData<UserResponse>;
// Resultat: { id: number; name: string }
 
type UserError = ExtractError<UserResponse>;
// Resultat: string

Det här är renare än att skriva manuella type guards för varje responsform.

Nyckelordet infer#

infer är mönstermatchning för typer. Det låter dig deklarera en typvariabel inuti en conditional type som TypeScript räknar ut åt dig.

Tänk på det som: "Jag vet att den här typen har en viss form. Plocka ut den del jag bryr mig om."

Extrahera returtyper#

typescript
type MyReturnType<T> = T extends (...args: unknown[]) => infer R ? R : never;
 
type Fn = (x: number, y: string) => boolean;
type Result = MyReturnType<Fn>; // boolean

TypeScript ser mönstret (...args) => something, matchar din funktion mot det och tilldelar returtypen till R.

Extrahera parametertyper#

typescript
type MyParameters<T> = T extends (...args: infer P) => unknown ? P : never;
 
type Fn = (x: number, y: string) => void;
type Params = MyParameters<Fn>; // [x: number, y: string]

Unwrapping av Promises#

Den här använder jag hela tiden när jag jobbar med asynkrona funktioner:

typescript
type UnpackPromise<T> = T extends Promise<infer U> ? U : T;
 
type A = UnpackPromise<Promise<string>>;  // string
type B = UnpackPromise<Promise<number[]>>; // number[]
type C = UnpackPromise<string>;            // string (genomströmning)

För djupt nästlade promises:

typescript
type DeepUnpackPromise<T> = T extends Promise<infer U>
  ? DeepUnpackPromise<U>
  : T;
 
type Deep = DeepUnpackPromise<Promise<Promise<Promise<number>>>>;
// Resultat: number

Extrahera elementtyper från arrayer#

typescript
type ElementOf<T> = T extends (infer E)[] ? E : never;
 
type Items = ElementOf<string[]>;         // string
type Mixed = ElementOf<(string | number)[]>; // string | number

Flera infer i en typ#

Du kan använda infer flera gånger för att extrahera olika delar:

typescript
type FirstAndLast<T extends unknown[]> =
  T extends [infer First, ...unknown[], infer Last]
    ? { first: First; last: Last }
    : never;
 
type Result = FirstAndLast<[1, 2, 3, 4]>;
// Resultat: { first: 1; last: 4 }

Verklighetsexempel: Typsäker eventextraktion#

Här är ett mönster jag använder i händelsedrivna system:

typescript
type EventMap = {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "order:created": { orderId: string; total: number };
  "order:shipped": { orderId: string; trackingId: string };
};
 
type EventName = keyof EventMap;
type EventPayload<E extends EventName> = EventMap[E];
 
// Extrahera alla användarrelaterade händelser
type UserEvents = Extract<EventName, `user:${string}`>;
// Resultat: "user:login" | "user:logout"
 
// Hämta payload för bara användarhändelser
type UserEventPayloads = EventPayload<UserEvents>;
// Resultat: { userId: string; timestamp: number } | { userId: string }

Mapped Types#

Mapped types låter dig skapa nya objekttyper genom att transformera varje egenskap i en befintlig typ. De är typsystemets map().

Grunderna#

typescript
type Readonly<T> = {
  readonly [K in keyof T]: T[K];
};
 
type Partial<T> = {
  [K in keyof T]?: T[K];
};

keyof T ger dig en union av alla egenskapsnycklar. [K in ...] itererar över var och en. T[K] är typen för den egenskapen.

Bygg utility types från grunden#

Required — gör alla egenskaper obligatoriska:

typescript
type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

Syntaxen -? tar bort den valfria modifieraren. På samma sätt tar -readonly bort readonly.

Pick — välj specifika egenskaper:

typescript
type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};
 
interface User {
  id: number;
  name: string;
  email: string;
  password: string;
}
 
type PublicUser = MyPick<User, "id" | "name" | "email">;
// { id: number; name: string; email: string }

Omit — ta bort specifika egenskaper:

typescript
type MyOmit<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
};
 
type SafeUser = MyOmit<User, "password">;
// { id: number; name: string; email: string }

Record — skapa en objekttyp med specifika nycklar och värdetyper:

typescript
type MyRecord<K extends string | number | symbol, V> = {
  [P in K]: V;
};
 
type StatusMap = MyRecord<"active" | "inactive" | "banned", boolean>;
// { active: boolean; inactive: boolean; banned: boolean }

Ommappning av nycklar med as#

TypeScript 4.1 introducerade ommappning av nycklar, som låter dig transformera egenskapsnamn:

typescript
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
 
interface Person {
  name: string;
  age: number;
  email: string;
}
 
type PersonGetters = Getters<Person>;
// {
//   getName: () => string;
//   getAge: () => number;
//   getEmail: () => string;
// }

Du kan också filtrera egenskaper genom att mappa om till never:

typescript
type OnlyStrings<T> = {
  [K in keyof T as T[K] extends string ? K : never]: T[K];
};
 
type StringProps = OnlyStrings<Person>;
// { name: string; email: string }

Det här är otroligt kraftfullt. Du itererar samtidigt över egenskaper, filtrerar dem och transformerar deras namn — allt på typnivå.

Praktiskt exempel: Formulärvalideringstyper#

Jag har använt det här mönstret i flera formulärbibliotek:

typescript
interface FormFields {
  username: string;
  email: string;
  age: number;
  bio: string;
}
 
type ValidationErrors<T> = {
  [K in keyof T]?: string[];
};
 
type TouchedFields<T> = {
  [K in keyof T]?: boolean;
};
 
type DirtyFields<T> = {
  [K in keyof T]?: boolean;
};
 
type FormState<T> = {
  values: T;
  errors: ValidationErrors<T>;
  touched: TouchedFields<T>;
  dirty: DirtyFields<T>;
  isValid: boolean;
  isSubmitting: boolean;
};
 
// Nu är ditt formulärtillstånd fullständigt typat baserat på fälten
type MyFormState = FormState<FormFields>;

Lägg till ett fält i FormFields och alla relaterade typer uppdateras automatiskt. Ta bort ett fält och kompilatorn fångar varje referens. Det är den sortens saker som förhindrar hela kategorier av buggar.

Template Literal Types#

Template literal types låter dig manipulera strängar på typnivå. De använder samma backtick-syntax som JavaScripts template literals, men för typer.

Grundläggande strängmanipulering#

typescript
type Greeting = `Hello, ${string}`;
 
const a: Greeting = "Hello, world";    // OK
const b: Greeting = "Hello, TypeScript"; // OK
// const c: Greeting = "Hi, world";    // Fel!

TypeScript tillhandahåller även inbyggda strängmanipuleringstyper:

typescript
type Upper = Uppercase<"hello">;     // "HELLO"
type Lower = Lowercase<"HELLO">;     // "hello"
type Cap = Capitalize<"hello">;      // "Hello"
type Uncap = Uncapitalize<"Hello">;  // "hello"

Mönster för händelsenamn#

Det är här template literal types verkligen lyser. Här är ett mönster jag använder för typsäkra händelsesystem:

typescript
type EventNames<T extends string> = `${T}Changed` | `${T}Deleted` | `${T}Created`;
 
type ModelEvents = EventNames<"user" | "order" | "product">;
// "userChanged" | "userDeleted" | "userCreated"
// | "orderChanged" | "orderDeleted" | "orderCreated"
// | "productChanged" | "productDeleted" | "productCreated"

Nio händelser från två typparametrar. Och de är alla typsäkra.

CSS-i-TypeScript-mönster#

Template literal types kan upprätthålla CSS-mönster på typnivå:

typescript
type CSSUnit = "px" | "rem" | "em" | "vh" | "vw" | "%";
type CSSValue = `${number}${CSSUnit}`;
 
function setWidth(value: CSSValue): void {
  // implementation
}
 
setWidth("100px");   // OK
setWidth("2.5rem");  // OK
setWidth("50%");     // OK
// setWidth("100");  // Fel: inte ett giltigt CSS-värde
// setWidth("big");  // Fel

Parsa strängar med template literals#

Du kan använda template literal types med infer för att parsa strängar:

typescript
type ParseRoute<T extends string> =
  T extends `${infer _Start}:${infer Param}/${infer Rest}`
    ? Param | ParseRoute<`/${Rest}`>
    : T extends `${infer _Start}:${infer Param}`
      ? Param
      : never;
 
type RouteParams = ParseRoute<"/users/:userId/posts/:postId">;
// Resultat: "userId" | "postId"

Det här är grunden för hur typsäkra routingbibliotek som tRPC fungerar. Strängen /users/:userId/posts/:postId parsas på typnivå för att extrahera parameternamn.

Kombinera template literals med mapped types#

Det är här saker blir riktigt kraftfulla:

typescript
type EventHandlers<T extends Record<string, unknown>> = {
  [K in keyof T as `on${Capitalize<string & K>}Change`]: (
    newValue: T[K],
    oldValue: T[K]
  ) => void;
};
 
interface ThemeConfig {
  color: string;
  fontSize: number;
  darkMode: boolean;
}
 
type ThemeHandlers = EventHandlers<ThemeConfig>;
// {
//   onColorChange: (newValue: string, oldValue: string) => void;
//   onFontSizeChange: (newValue: number, oldValue: number) => void;
//   onDarkModeChange: (newValue: boolean, oldValue: boolean) => void;
// }

Från ett enkelt config-interface får du fullständigt typade händelsehanterare med korrekta parametertyper. Det här är den sortens typnivåkod som faktiskt sparar utvecklingstid.

Avancerat: Dot-notation-sökvägstyper#

Här är ett mer avancerat mönster — generera alla möjliga dot-notation-sökvägar genom ett nästlat objekt:

typescript
type DotPaths<T, Prefix extends string = ""> = T extends object
  ? {
      [K in keyof T & string]: T[K] extends object
        ? `${Prefix}${K}` | DotPaths<T[K], `${Prefix}${K}.`>
        : `${Prefix}${K}`;
    }[keyof T & string]
  : never;
 
interface Config {
  database: {
    host: string;
    port: number;
    credentials: {
      username: string;
      password: string;
    };
  };
  server: {
    port: number;
  };
}
 
type ConfigPaths = DotPaths<Config>;
// "database" | "database.host" | "database.port"
// | "database.credentials" | "database.credentials.username"
// | "database.credentials.password" | "server" | "server.port"

Nu kan du skriva en getConfig-funktion som autocomplete:ar varje möjlig sökväg och returnerar rätt typ för varje:

typescript
type GetValueByPath<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? GetValueByPath<T[Key], Rest>
      : never
    : P extends keyof T
      ? T[P]
      : never;
 
function getConfig<P extends ConfigPaths>(path: P): GetValueByPath<Config, P> {
  // implementation
  const keys = path.split(".");
  let result: unknown = config;
  for (const key of keys) {
    result = (result as Record<string, unknown>)[key];
  }
  return result as GetValueByPath<Config, P>;
}
 
const host = getConfig("database.host");         // typ: string
const port = getConfig("database.port");         // typ: number
const username = getConfig("database.credentials.username"); // typ: string

Full autocomplete, full typsäkerhet, noll runtime-overhead från typerna.

Rekursiva typer#

TypeScript stöder rekursiva typdefinitioner, vilket låter dig hantera godtyckligt nästlade strukturer.

Deep Partial#

Den inbyggda Partial<T> gör bara egenskaper på översta nivån valfria. För nästlade objekt behöver du rekursion:

typescript
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object
    ? T[K] extends Array<infer U>
      ? Array<DeepPartial<U>>
      : DeepPartial<T[K]>
    : T[K];
};
 
interface Settings {
  theme: {
    colors: {
      primary: string;
      secondary: string;
    };
    fonts: {
      heading: string;
      body: string;
    };
  };
  notifications: {
    email: boolean;
    push: boolean;
  };
}
 
type PartialSettings = DeepPartial<Settings>;
// Varje nästlad egenskap är nu valfri

Observera arraykontrollen: utan den skulle arrayer behandlas som objekt och deras numeriska index skulle bli valfria, vilket inte är vad du vill ha.

Deep Readonly#

Samma mönster, annorlunda modifierare:

typescript
type DeepReadonly<T> = {
  readonly [K in keyof T]: T[K] extends object
    ? T[K] extends Function
      ? T[K]
      : T[K] extends Array<infer U>
        ? ReadonlyArray<DeepReadonly<U>>
        : DeepReadonly<T[K]>
    : T[K];
};

Function-kontrollen är viktig — utan den skulle funktionsegenskaper få readonly applicerat på sin interna struktur, vilket inte är meningsfullt.

JSON-typ#

Här är en klassisk rekursiv typ — representerar alla giltiga JSON-värden:

typescript
type JsonPrimitive = string | number | boolean | null;
type JsonArray = JsonValue[];
type JsonObject = { [key: string]: JsonValue };
type JsonValue = JsonPrimitive | JsonArray | JsonObject;
 
function parseJson(input: string): JsonValue {
  return JSON.parse(input);
}
 
function isJsonObject(value: JsonValue): value is JsonObject {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

Det här är mycket bättre än att använda any för JSON-parsning. Det berättar för konsumenten "du får tillbaka något, men du måste narrowa det innan du använder det." Vilket är exakt sanningen.

Rekursiv typ för nästlade menyer#

Ett praktiskt exempel — typa en navigeringsmenystruktur:

typescript
interface MenuItem {
  label: string;
  href: string;
  icon?: string;
  children?: MenuItem[];
}
 
type Menu = MenuItem[];
 
const navigation: Menu = [
  {
    label: "Products",
    href: "/products",
    children: [
      {
        label: "Software",
        href: "/products/software",
        children: [
          { label: "IDE", href: "/products/software/ide" },
          { label: "CLI Tools", href: "/products/software/cli" },
        ],
      },
      { label: "Hardware", href: "/products/hardware" },
    ],
  },
  { label: "About", href: "/about" },
];

Den rekursiva children-egenskapen ger dig oändlig nästling med full typsäkerhet.

Mönster från verkligheten#

Låt mig dela några mönster jag faktiskt har använt i produktionssystem.

Builder-mönstret med typer#

Builder-mönstret blir markant mer användbart när typsystemet spårar vad som har satts:

typescript
type RequiredKeys = "host" | "port" | "database";
 
type BuilderState = {
  [K in RequiredKeys]?: true;
};
 
class DatabaseConfigBuilder<State extends BuilderState = {}> {
  private config: Partial<{
    host: string;
    port: number;
    database: string;
    ssl: boolean;
    poolSize: number;
  }> = {};
 
  setHost(host: string): DatabaseConfigBuilder<State & { host: true }> {
    this.config.host = host;
    return this as unknown as DatabaseConfigBuilder<State & { host: true }>;
  }
 
  setPort(port: number): DatabaseConfigBuilder<State & { port: true }> {
    this.config.port = port;
    return this as unknown as DatabaseConfigBuilder<State & { port: true }>;
  }
 
  setDatabase(db: string): DatabaseConfigBuilder<State & { database: true }> {
    this.config.database = db;
    return this as unknown as DatabaseConfigBuilder<State & { database: true }>;
  }
 
  setSsl(ssl: boolean): DatabaseConfigBuilder<State> {
    this.config.ssl = ssl;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  setPoolSize(size: number): DatabaseConfigBuilder<State> {
    this.config.poolSize = size;
    return this as unknown as DatabaseConfigBuilder<State>;
  }
 
  build(
    this: DatabaseConfigBuilder<{ host: true; port: true; database: true }>
  ): Required<Pick<typeof this.config, "host" | "port" | "database">> &
    Partial<Pick<typeof this.config, "ssl" | "poolSize">> {
    return this.config as ReturnType<typeof this.build>;
  }
}
 
// Det här kompilerar:
const config = new DatabaseConfigBuilder()
  .setHost("localhost")
  .setPort(5432)
  .setDatabase("myapp")
  .setSsl(true)
  .build();
 
// Det här misslyckas vid kompilering — saknar .setDatabase():
// const bad = new DatabaseConfigBuilder()
//   .setHost("localhost")
//   .setPort(5432)
//   .build(); // Fel: 'database' saknas

Metoden build() blir bara anropbar när alla obligatoriska fält har satts. Det här fångas vid kompilering, inte vid körning.

Typsäker Event Emitter#

Här är en event emitter där payloadtyperna upprätthålls:

typescript
type EventMap = Record<string, unknown>;
 
class TypedEventEmitter<Events extends EventMap> {
  private listeners: {
    [K in keyof Events]?: Array<(payload: Events[K]) => void>;
  } = {};
 
  on<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    if (!this.listeners[event]) {
      this.listeners[event] = [];
    }
    this.listeners[event]!.push(listener);
  }
 
  emit<K extends keyof Events>(event: K, payload: Events[K]): void {
    this.listeners[event]?.forEach((listener) => listener(payload));
  }
 
  off<K extends keyof Events>(
    event: K,
    listener: (payload: Events[K]) => void
  ): void {
    const handlers = this.listeners[event];
    if (handlers) {
      this.listeners[event] = handlers.filter((h) => h !== listener) as typeof handlers;
    }
  }
}
 
// Användning
interface AppEvents {
  "user:login": { userId: string; timestamp: number };
  "user:logout": { userId: string };
  "notification:new": { message: string; level: "info" | "warning" | "error" };
}
 
const emitter = new TypedEventEmitter<AppEvents>();
 
// Fullständigt typat — IDE:n autocomplete:ar händelsenamn och payloadformer
emitter.on("user:login", (payload) => {
  console.log(payload.userId);     // string
  console.log(payload.timestamp);  // number
});
 
// Typfel: payload matchar inte
// emitter.emit("user:login", { userId: "123" });
// Fel: saknar 'timestamp'
 
// Typfel: händelsen finns inte
// emitter.on("user:signup", () => {});
// Fel: "user:signup" finns inte i AppEvents

Jag har använt det här mönstret i tre olika produktionsprojekt. Det fångar hela kategorier av händelserelaterade buggar vid kompilering.

Discriminated Unions med uttömmande kontroll#

Discriminated unions är troligen det enskilt mest användbara mönstret i TypeScript. Kombinerat med uttömmande kontroll garanterar de att du hanterar varje fall:

typescript
type Shape =
  | { kind: "circle"; radius: number }
  | { kind: "rectangle"; width: number; height: number }
  | { kind: "triangle"; base: number; height: number };
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      // Den här raden säkerställer uttömmande kontroll
      const _exhaustive: never = shape;
      return _exhaustive;
  }
}

Om någon lägger till en ny formvariant (säg "pentagon") kommer den här funktionen inte att kompilera eftersom never inte kan tilldelas ett värde. Kompilatorn tvingar dig att hantera varje fall.

Jag tar det ett steg längre med en hjälpfunktion:

typescript
function assertNever(value: never, message?: string): never {
  throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
 
function area(shape: Shape): number {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle":
      return 0.5 * shape.base * shape.height;
    default:
      assertNever(shape, `Unknown shape kind: ${(shape as Shape).kind}`);
  }
}

Typsäkra tillståndsmaskiner#

Discriminated unions modellerar tillståndsmaskiner vackert:

typescript
type ConnectionState =
  | { status: "disconnected" }
  | { status: "connecting"; attempt: number }
  | { status: "connected"; socket: WebSocket; connectedAt: Date }
  | { status: "error"; error: Error; lastAttempt: Date };
 
type ConnectionAction =
  | { type: "CONNECT" }
  | { type: "CONNECTED"; socket: WebSocket }
  | { type: "DISCONNECT" }
  | { type: "ERROR"; error: Error }
  | { type: "RETRY" };
 
function connectionReducer(
  state: ConnectionState,
  action: ConnectionAction
): ConnectionState {
  switch (state.status) {
    case "disconnected":
      if (action.type === "CONNECT") {
        return { status: "connecting", attempt: 1 };
      }
      return state;
 
    case "connecting":
      if (action.type === "CONNECTED") {
        return {
          status: "connected",
          socket: action.socket,
          connectedAt: new Date(),
        };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "connected":
      if (action.type === "DISCONNECT") {
        state.socket.close();
        return { status: "disconnected" };
      }
      if (action.type === "ERROR") {
        return {
          status: "error",
          error: action.error,
          lastAttempt: new Date(),
        };
      }
      return state;
 
    case "error":
      if (action.type === "RETRY") {
        return { status: "connecting", attempt: 1 };
      }
      if (action.type === "DISCONNECT") {
        return { status: "disconnected" };
      }
      return state;
 
    default:
      assertNever(state, "Unknown connection state");
  }
}

Varje tillstånd har bara de egenskaper som är meningsfulla för det tillståndet. Du kan inte komma åt socket när du är frånkopplad. Du kan inte komma åt error när du är ansluten. Typsystemet upprätthåller tillståndsmaskinens begränsningar.

Branded Types för domänsäkerhet#

Ytterligare ett mönster jag anser vara väsentligt — att använda branded types för att förhindra att värden som delar samma underliggande typ blandas ihop:

typescript
type Brand<T, B extends string> = T & { readonly __brand: B };
 
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type Email = Brand<string, "Email">;
 
function createUserId(id: string): UserId {
  return id as UserId;
}
 
function createOrderId(id: string): OrderId {
  return id as OrderId;
}
 
function getUser(id: UserId): Promise<User> {
  // ...
}
 
function getOrder(id: OrderId): Promise<Order> {
  // ...
}
 
const userId = createUserId("user_123");
const orderId = createOrderId("order_456");
 
getUser(userId);   // OK
// getUser(orderId); // Fel! OrderId är inte tilldelningsbart till UserId

Både UserId och OrderId är strängar vid körning. Men vid kompilering är de distinkta typer. Du kan bokstavligen inte skicka ett order-ID där ett användar-ID förväntas. Det här har fångat riktiga buggar i varje projekt där jag har använt det.

Vanliga fallgropar#

Avancerade typer är kraftfulla, men de kommer med fällor. Här är vad jag har lärt mig den hårda vägen.

Begränsningar för cirkulära referenser#

TypeScript har en rekursionsdjupsgräns för typer (för närvarande runt 50 nivåer, men det varierar). Om du går för djupt får du det fruktade felet "Type instantiation is excessively deep and possibly infinite."

typescript
// Det här kommer att nå rekursionsgränsen för djupt nästlade objekt
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
};
 
// Fix: Lägg till en djupräknare
type DeepPartialSafe<T, Depth extends number[] = []> =
  Depth["length"] extends 10
    ? T
    : {
        [K in keyof T]?: T[K] extends object
          ? DeepPartialSafe<T[K], [...Depth, 0]>
          : T[K];
      };

Tricket med djupräknare använder en tupel som växer med varje rekursivt steg. När den når din gräns stannar rekursionen.

Prestandaimplikationer#

Komplexa typer kan avsevärt sakta ner din editor och byggtider. Jag har sett projekt där en enda överdriven typ lade till 3 sekunder till varje tangenttrycknings feedbackloop.

Varningssignaler:

  • Din IDE tar mer än 2 sekunder att visa autocomplete
  • tsc --noEmit tar märkbart längre tid efter att du lagt till en typ
  • Du ser felet "Type instantiation is excessively deep"
typescript
// Det här är för fiffigt — det genererar en kombinatorisk explosion
type AllCombinations<T extends string[]> = T extends [
  infer First extends string,
  ...infer Rest extends string[]
]
  ? `${First}${AllCombinations<Rest>}` | AllCombinations<Rest>
  : "";
 
// Gör inte det här med mer än 5-6 element

När man INTE ska använda avancerade typer#

Det här kan vara det viktigaste avsnittet. Avancerade typer bör användas när de förhindrar buggar eller förbättrar utvecklarupplevelsen. De bör inte användas för att visa upp sig.

Använd dem inte när:

  • En enkel Record<string, unknown> skulle räcka
  • Typen bara används på ett ställe och en konkret typ skulle vara tydligare
  • Du lägger mer tid på att felsöka typen än den skulle spara
  • Ditt team inte kan läsa eller underhålla den
  • En runtime-kontroll skulle vara mer lämplig

Använd dem när:

  • Du har ett mönster som upprepas över många typer (mapped types)
  • Returtypen för en funktion beror på dess input (conditional types)
  • Du bygger ett biblioteks-API som behöver vara självdokumenterande
  • Du vill göra illegala tillstånd orepresenterbara
  • Du är trött på att samma kategori av buggar dyker upp i kodgranskningar
typescript
// Överdesignat — gör inte det här för en enkel config
type Config = DeepReadonly<
  DeepRequired<
    Merge<DefaultConfig, Partial<UserConfig>>
  >
>;
 
// Gör bara det här
interface Config {
  readonly host: string;
  readonly port: number;
  readonly debug: boolean;
}

Min tumregel: om det tar längre tid att förklara typen än att förklara buggen den förhindrar, förenkla den.

Felsöka komplexa typer#

När en komplex typ inte fungerar använder jag den här hjälparen för att "se" vad TypeScript har löst:

typescript
// Expanderar en typ för inspektion i IDE:ns hover-tooltips
type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};
 
// Använd den för att felsöka
type Problematic = SomeComplexType<Input>;
type Debug = Prettify<Problematic>;
// Hovra nu över Debug i din IDE för att se den lösta typen

Tricket med & {} tvingar TypeScript att ivrigt evaluera typen istället för att visa dig typaliaset. Det har sparat mig timmar av förvirring.

En annan teknik — isolera och testa inkrementellt:

typescript
// Istället för att felsöka allt på en gång:
type Final = StepThree<StepTwo<StepOne<Input>>>;
 
// Bryt isär det:
type AfterStep1 = StepOne<Input>;       // hovra för att kontrollera
type AfterStep2 = StepTwo<AfterStep1>;  // hovra för att kontrollera
type AfterStep3 = StepThree<AfterStep2>; // hovra för att kontrollera

Sammanfattning#

  • Conditional types (T extends U ? X : Y) är if/else för typer. Se upp för distributivt beteende med unions.
  • infer är mönstermatchning — använd det för att extrahera typer från strukturer som funktionssignaturer, promises och arrayer.
  • Mapped types ({ [K in keyof T]: ... }) itererar över egenskaper. Kombinera med as för ommappning och filtrering av nycklar.
  • Template literal types manipulerar strängar på typnivå. Kombinerade med mapped types är de otroligt kraftfulla för API-design.
  • Rekursiva typer hanterar nästlade strukturer men behöver djupbegränsningar för att undvika kompilatorexplosioner.
  • Discriminated unions med uttömmande kontroll är det enskilt mest värdefulla mönstret i TypeScript. Använd dem överallt.
  • Branded types förhindrar att värden som delar samma underliggande typ blandas ihop. Enkla att implementera, stor effekt.
  • Överdesigna inte typer. Om typen är svårare att förstå än buggen den förhindrar, förenkla den. Målet är att göra din kodbas säkrare, inte att vinna en typgolftävling.

TypeScripts typsystem är Turing-komplett, vilket innebär att du kan göra nästan vad som helst med det. Konsten är att veta när du bör.

Relaterade inlägg