Strona główna / Artykuły

Te wzorce TypeScript, które zamieniają błędy w czasie wykonywania na błędy kompilacji

Naucz się dziesięciu technik TypeScript, od zjednoczeń dyskryminowanych i funkcji satisfies po typy branded oraz infer, które pozwalają kompilatorowi odrzucić nieważne stany przed wypuszczeniem kodu.

Wydawca: REACTAPP.TOP12 min czytania2744 słów

Źródła: Top 10 TypeScript Techniques Senior Developers Secretly Use

Wiele zespołów przyjmuje TypeScript, dodając adnotacje do istniejącego JavaScriptu i na tym poprzestają. To pomaga w wykrywaniu błędów pisowni, ale pozostawia większość systemu typów niewykorzystaną. Większą zaletą jest kodowanie zasad jako typów, dzięki czemu całe grupy błędów uniemożliwiają skompilowanie kodu i nie jest potrzebna żadna weryfikacja w czasie wykonywania. Poniżej przedstawiono dziesięć technik, które to umożliwiają, wraz z informacjami o sytuacjach, gdy każda z nich przynosi korzyści.

Jawnie modeluj stan za pomocą zjednoczeń dyskryminowanych

Powszechnym nawykiem jest opisywanie wartości jako zbioru opcjonalnych pól i poleganie na tym, że użytkownicy sprawdzą właściwą kombinację. Nic poniżej nie zapobiega ustawieniu zarówno data, jak i error.

// nothing stops you from having both data and error at once
type ApiResponse = {
  data?: User
  error?: string
  loading?: boolean
}

// consumer has no idea what to check
function handleResponse(res: ApiResponse) {
  if (res.data) { /* maybe fine */ }
  // what if data AND error are both set?
}

Zamiast tego zbitka dyskryminowana wymienia dopuszczalne formy. Każda wariantowa lista ma wspólne pole literalne (status), a TypeScript na jego podstawie zawęża możliwe wartości: w gałęzi success gwarantowane jest obecność res.data, a w gałęzi error – res.message.

type ApiResponse =
  | { status: 'loading' }
  | { status: 'success'; data: User }
  | { status: 'error'; message: string }

function handleResponse(res: ApiResponse) {
  switch (res.status) {
    case 'loading':
      return showSpinner()
    case 'success':
      // TypeScript KNOWS res.data exists here
      return renderUser(res.data)
    case 'error':
      // TypeScript KNOWS res.message exists here
      return showError(res.message)
  }
}

Gdy ktoś później doda czwarty stan, każdy switch, który go ignoruje, może stać się przyczyną błędu kompilacji, szczególnie przy sprawdzaniu wyczerpującości wszystkich możliwych wartości, o którym mowa niżej.

Waliduj obiekty za pomocą funkcji satisfies bez ich rozszerzania

Od wersji TypeScript 4.9 dostępna jest funkcja satisfies, która naprawia subtelny problem z adnotacjami. Wyrażenie const config: Config = ... sprawdza obiekt, ale typ zmiennej staje się dokładnie Config, więc config1.theme ma postać pełnej unii typów zamiast 'dark'. Dzięki satisfies obiekt nadal jest sprawdzany (błędy polegają na błędach w nazwach kluczy), ale zachowuje się dokładny typ wywnioskowany: config2.theme pozostaje 'dark', a secondary nadal jest uznawany za ważny klucz.

type Config = {
  theme: 'light' | 'dark' | 'system'
  colors: Record<string, string>
}
// with a normal annotation you lose the literal type
const config1: Config = {
  theme: 'dark',
  colors: { primary: '#3d6fe8' }
}
// config1.theme is now 'light' | 'dark' | 'system', not 'dark'

// with satisfies, the shape is validated but types stay narrow
const config2 = {
  theme: 'dark',
  colors: { primary: '#3d6fe8', secondary: '#7c3aed' }
} satisfies Config// config2.theme is still 'dark' — the literal type is preserved
// config2.colors.primary is still 'string' — shape is validated
// if you typo a field name, TypeScript still catches it

Używaj adnotacji, gdy chcesz szerszy typ, na przykład dla wartości, którą zamierzasz przypisać ponownie; stosuj satisfies, gdy potrzebujesz zarówno sprawdzenia, jak i dokładnego wywnioskowania typu.

Tworzenie unii ciągów znakowych za pomocą typów literalnych szablonowych

TypeScript może tworzyć typy ciągów znaków za pomocą składni z użyciem cudzysłowu odwróconego. Unia w miejscu zastępczym rozszerza się o każdą możliwą kombinację, co jest przydatne dla nazw obsługi zdarzeń, domyślnych właściwości CSS oraz punktów końcowych w formacie METHOD /path.

type EventName<T extends string> = `on${Capitalize<T>}`
// produces: 'onClick' | 'onChange' | 'onSubmit'

type DomEvents = EventName<'click' | 'change' | 'submit'>
// type safe CSS properties

type CSSVar<T extends string> = `--${T}`
type AppVars = CSSVar<'primary' | 'secondary' | 'bg'>
// produces: '--primary' | '--secondary' | '--bg'
// type safe API routes

type HttpMethod = 'GET' | 'POST' | 'DELETE'
type Route = '/users' | '/orders' | '/products'
type ApiEndpoint = `${HttpMethod} ${Route}`
// produces: 'GET /users' | 'GET /orders' | 'POST /users' | ...

function callApi(endpoint: ApiEndpoint) { /* ... */ }
callApi('GET /users')     // valid
callApi('PATCH /users')   // TypeScript error: not in the type

callApi('PATCH /users') zostaje odrzucony, ponieważ PATCH nie znajduje się w typie HttpMethod. Kombinacje mnożą się szybko, dlatego należy utrzymywać unie wejściowe w małym rozmiarze, aby uniknąć spowolnienia kompilatora.

Rozdzielaj podobne wartości za pomocą typów specjalnych

TypeScript porównuje typy pod kątem struktury, więc type UserId = string jest w rzeczywistości równoznaczne z string, a identyfikator użytkownika może być bez problemu przekazany do funkcji oczekującej identyfikatora zamówienia. Użycie typów specjalnych polega na połączeniu pierwotnego typu z właściwością „widmową”, która nie istnieje w czasie wykonywania, co sprawia, że oba typy stają się niekompatybilne. Funkcje konstruktorów stają się wtedy jedynym sposobem na tworzenie takich wartości oraz naturalnym miejscem do ich walidacji.

// without branding — TypeScript cannot tell these apart
type UserId = string
type OrderId = string

function deleteOrder(id: OrderId) { /* ... */ }
const userId: UserId = 'user-42'
deleteOrder(userId) // no error - catastrophic bug

// with branded types - structurally different, enforced
type Brand<T, B> = T & { readonly _brand: B }
type UserId  = Brand<string, 'UserId'>
type OrderId = Brand<string, 'OrderId'>
// constructor functions act as the only way to create these types
const UserId  = (id: string) => id as UserId
const OrderId = (id: string) => id as OrderId
const userId  = UserId('user-42')
const orderId = OrderId('order-99')
deleteOrder(userId)  // TypeScript error: UserId is not assignable to OrderId
deleteOrder(orderId) // works fine

Dobrymi kandydatami są identyfikatory (UserId, OrderId, ProductId), sprawdzone wartości takie jak EmailAddress, HashedPassword lub ValidatedInput, a także jednostki łatwo mylone ze sobą, np. milisekundy w porównaniu z sekundami lub dolary w porównaniu z centami. W tym fragmencie każdy alias jest deklarowany dwa razy w celu porównania; w prawdziwym kodzie należy zachować tylko wersję oznaczoną nazwą marki.

Wyodrębnianie typów z innych typów za pomocą infer

Wewnątrz typu warunkowego infer deklaruje slot, który TypeScript wypełnia poprzez dopasowywanie wzorów. W ten sposób tworzone są ReturnType, Parameters i Awaited, a także umożliwia on pisanie własnych narzędzi do wyodrębniania elementów.

// extract the return type of any function
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never

function getUser() { return { id: 1, name: 'Alice' } }
type User = ReturnType<typeof getUser>
// User = { id: number, name: string }
// extract the resolved value of a Promise
type UnwrapPromise<T> = T extends Promise<infer V> ? V : T
type Data = UnwrapPromise<Promise<User>>
// Data = User
// extract the element type of an array
type ArrayElement<T> = T extends (infer E)[] ? E : never
type Names = ArrayElement<string[]>
// Names = string
// extract first argument of any function
type FirstArg<T> = T extends (first: infer A, ...rest: any[]) => any ? A : never
type Arg = FirstArg<typeof getUser>
// Arg = never (no args), try a fn with args:
type A2 = FirstArg<(id: string) => void>
// A2 = string

Każdą definicję należy odczytywać jako „jeśli T pasuje do tej struktury, co powinno trafić do pola infer?”. Jeśli wzorzec nie pasuje, wygrywa niesprawdzona ścieżka, dlatego FirstArg funkcji bez argumentów jest zawsze never. Nadaj swoim pomocniczym funkcjom odrębne nazwy, zamiast przyćmiewać globalną zmienną ReturnType.

Zmuszanie niekompletnych struktur switch do zwracania wartości never

Łącz unie z pomocniczą funkcją, która przyjmuje wyłącznie wartość never. Gdy każda z wariantów ma swoją ścieżkę case, typ pozostawiony w sekcji default jest never, dzięki czemu kod kompiluje się poprawnie. Jeśli dodasz element hexagon bez jego obsługi, pozostały typ spowoduje błąd podczas kompilacji.

type Shape =
  | { kind: 'circle';    radius: number }
  | { kind: 'rectangle'; width: number; height: number }
  | { kind: 'triangle';  base: number;  height: number }

// this helper makes the default case scream at compile time
function assertNever(value: never): never {
  throw new Error(`Unhandled case: ${JSON.stringify(value)}`)
}
function getArea(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:
      return assertNever(shape)
      // if you add 'hexagon' to Shape and forget to handle it,
      // TypeScript throws an error RIGHT HERE at compile time
  }
}

Ponieważ pomocnicza funkcja również rzuca wyjątki, zapobiega to pojawieniu się nieoczekiwanych wartości pochodzących z zewnątrz systemu typów, takich jak odpowiedzi API.

Kształtowanie typów za pomocą typów mapowanych i przekierowywania kluczy

Typy mapowane przechodzą przez klucze danego typu, aby stworzyć nowy typ, a klauzula as zmienia nazwy kluczy lub je usuwa, mapując je na never. Przykłady pokazują budowę rekurencyjnych, częściowych sygnatur getterów oraz filtru, który zachowuje tylko metody.

// make all properties optional but keep their types
type DeepPartial<T> = {
  [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K]
}

// prefix all keys with 'get'
type Getters<T> = {
  [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K]
}
type User = { id: string; name: string; email: string }
type UserGetters = Getters<User>
// { getId: () => string, getName: () => string, getEmail: () => string }
// pick only function properties from a type
type FunctionKeys<T> = {
  [K in keyof T as T[K] extends Function ? K : never]: T[K]
}
type Service = { id: string; save(): void; delete(): void; name: string }
type ServiceMethods = FunctionKeys<Service>
// { save: () => void, delete: () => void }

string & K jest konieczne, ponieważ klucze mogą być również liczbami lub symbolami, które Capitalize odrzuca. Ten prosty DeepPartial rekurencyjnie przetwarza również funkcje i tablice, co jest w porządku dla zwykłych danych, ale może wymagać dalszej optymalizacji.

Nauka kompilatora własnych sprawdzeń za pomocą strażników typu

TypeScript zawęża typy przy użyciu typeof, instanceof oraz porównań literówkowych, ale nie w ramach własnych funkcji sprawdzających. Predykat typu (animal is Cat) określa, co funkcja logiczna ma udowodnić; funkcja twierdzenia (asserts val is NonNullable) rzuca błąd w przypadku niepowodzenia i zawęża wszystko po wywołaniu.

type Cat = { kind: 'cat'; meows: boolean }
type Dog = { kind: 'dog'; barks: boolean }
type Animal = Cat | Dog

// type predicate - return type tells TypeScript what the guard proves
function isCat(animal: Animal): animal is Cat {
  return animal.kind === 'cat'
}
function speak(animal: Animal) {
  if (isCat(animal)) {
    console.log(animal.meows) // TypeScript knows animal is Cat here
  } else {
    console.log(animal.barks) // TypeScript knows animal is Dog here
  }
}
// assertion function - throws if condition not met, narrows after the call
function assertDefined<T>(val: T): asserts val is NonNullable<T> {
  if (val === null || val === undefined) {
    throw new Error('Value is null or undefined')
  }
}
const user = getUser() // returns User | null
assertDefined(user)    // throws at runtime if null
console.log(user.name) // TypeScript knows user is User here, not User | null

Kompilator ufa tym sygnaturom bez ich weryfikacji, więc błędna funkcja ochronna wprowadza w błąd każdego wywołującego. Trzymaj je proste i przetestowane.

Zachowuj precyzję wartości literówkowych za pomocą as const

TypScript domyślnie rozszerza literale: 'production' staje się string. Wyrażenie as const fiksuje je do najwęższych typów i czyni je readonly. Prawdziwą zaletą jest możliwość tworzenia związków typów na podstawie danych za pomocą typeof oraz dostępu indeksowego, dzięki czemu tablica lub obiekt stają się jedynym źródłem prawdy.

// without as const — types are widened
const config = {
  env: 'production',     // inferred as string, not 'production'
  port: 3000,             // inferred as number, not 3000
  features: ['auth', 'search'] // inferred as string[]
}

// with as const - every value is exactly as written
const config = {
  env: 'production',
  port: 3000,
  features: ['auth', 'search']
} as const
// config.env is 'production', not string
// config.port is 3000, not number
// config.features is readonly ['auth', 'search']
// the real power: derive union types from the values
const ROLES = ['admin', 'editor', 'viewer'] as const
type Role = typeof ROLES[number]
// Role = 'admin' | 'editor' | 'viewer'
// add a new role to the array and the type updates automatically
const ROUTES = {
  home:    '/',
  users:   '/users',
  profile: '/profile'
} as const
type AppRoute = typeof ROUTES[keyof typeof ROUTES]
// AppRoute = '/' | '/users' | '/profile'

W tym fragmencie zdefiniowano config dwa razy w celu pokazania różnicy. Dzięki takiemu podejściu dodanie nowej roli lub ścieżki aktualizuje wszystkie zależne typy bez konieczności duplikowania danych.

Zmienianie kształtów zwracanych wartości i danych wejściowych za pomocą typów warunkowych

Typy warunkowe umożliwiają funkcji generycznej zwracanie różnych struktur w zależności od jej argumentów, i to właśnie w ten sposób Prisma typuje wyniki funkcji include i select. Tutaj includePosts: true generuje typ zawierający pole posts, a w drugim przykładzie pole publishedAt jest wymagane tylko w trybie publish.

// function returns different shapes based on a flag
type UserResponse<T extends boolean> =
  T extends true
    ? { user: User; posts: Post[] }   // with posts
    : { user: User }                   // without posts

function fetchUser<T extends boolean>(
  id: string,
  includePosts: T
): Promise<UserResponse<T>> {
  /* implementation */
}
// TypeScript knows exactly what comes back
const result1 = await fetchUser('123', true)
result1.posts   // valid - TypeScript knows posts exists
const result2 = await fetchUser('123', false)
result2.posts   // TypeScript error - posts does not exist on this return type
// another real world example: optional fields based on mode
type CreateMode = 'draft' | 'publish'
type ArticleInput<M extends CreateMode> =
  M extends 'publish'
    ? { title: string; content: string; publishedAt: Date }
    : { title: string; content?: string }
function createArticle<M extends CreateMode>(
  mode: M,
  input: ArticleInput<M>
) { /* ... */ }
// publishedAt is REQUIRED when mode is 'publish'
createArticle('publish', { title: 'Hey', content: '...', publishedAt: new Date() })
// publishedAt is NOT required when mode is 'draft'
createArticle('draft',   { title: 'Hey' })

Wewnątrz implementacji TypeScript zazwyczaj nie może udowodnić, że zwrócona wartość odpowiada nierozwiązanemu typowi warunkowemu, dlatego należy się spodziewać konwersji. W przypadku dwóch lub trzech wariantów nadładowania są często jaśniejsze.

Jak wszystko się łączy

To nie są pojedyncze sztuczki. Unie definiują stany, never sprawia, że ich obsługa jest wyczerpująca, as const oraz literały szablonowe tworzą unie, a typy mapowane i warunkowe wywodzą resztę. Typy stają się warstwą logiki zamiast dokumentacją. Poniższy skrót służy jako lista kontrolna podczas przeglądu; zakłada istnienie typów takich jak Config, User i Animal.

// 01. Discriminated unions — model every valid state explicitly
type State = | { status: 'idle' } | { status: 'ok'; data: T } | { status: 'err'; msg: string }
// 02. satisfies - validate shape without widening literal types
const x = { mode: 'dark' } satisfies Config
// 03. Template literal types - build string unions programmatically
type Event<T extends string> = `on${Capitalize<T>}`
// 04. Branded types - prevent mixing structurally identical primitives
type UserId = string & { readonly _brand: 'UserId' }
// 05. infer - extract types from inside other types
type Unwrap<T> = T extends Promise<infer V> ? V : T
// 06. assertNever - exhaustiveness check at compile time
function assertNever(x: never): never { throw new Error('Unhandled case') }
// 07. Mapped types - transform type shapes programmatically
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] }
// 08. Type guards - teach TypeScript how to narrow your types
function isCat(a: Animal): a is Cat { return a.kind === 'cat' }
// 09. as const - lock values to literals, derive union types from data
const ROLES = ['admin', 'editor'] as const
type Role = typeof ROLES[number]
// 10. Conditional types - return different shapes based on input
type Res<T extends boolean> = T extends true ? { data: User; posts: Post[] } : { data: User }

Główne wnioski

  • Niech preferowane będą precyzyjne struktury zamiast pól opcjonalnych; unie w połączeniu z assertNever zamieniają brakujące przypadki na błędy podczas budowania.
  • Używaj satisfies i as const, aby weryfikować wartości, zachowując przy tym typy literackie.
  • Nadaj znaczące nazwy prymitywom, aby ID i jednostki nie mogły zostać zamienione.
  • Obliczaj typy za pomocą infer, typów mapowanych i warunkowych, ale utrzymuj ich czytelność.
  • Traktuj własne funkcje obronne i konwersje jako granice zaufania.
  • Aby w szerszym zakresie zapisać reguły biznesowe, zobacz modelowanie domen w TypeScript.

    Literatura pokrewna