Narzędzia potężne TypeScript: extends, infer, keyof Maps oraz as Remaps
Twórz typy na poziomie bibliotek poprzez łączenie warunków, inferencji, pętli mapowanych oraz przemapowywania kluczy — to ten sam zestaw narzędzi, który znajduje się u podstaw Zod i tRPC.
Po miesiącach pracy z TypeScript, wbudowane narzędzia takie jak Partial, Pick i Omit w końcu przestają odpowiadać rzeczywistym potrzebom bibliotek: wyodrębnianie typu zwracanego przez funkcję, transformacja każdego klucza lub tworzenie typów dostosowujących się do innych typów. Cztery funkcje łączą się, aby umożliwić osiągnięcie tego poziomu: warunkowe extends, infer, pętle mapowane nad keyof oraz przekierowanie kluczy za pomocą as. Pojedynczo są one łatwe do zrozumienia; razem umożliwiają realizację wzorców stosowanych w Zod, tRPC i React Router.
1. Typy warunkowe z extends
W kontekście typów extends funkcjonuje jak instrukcja if:
type IsString<T> = T extends string ? true : false;
type A = IsString<"hello">; // true
type B = IsString<42>; // false
Tłumaczenie: jeśli T może być przypisany do typu string, zwrócić true, w przeciwnym razie false.
Warunki stają się silniejsze, gdy mają charakter dystrybutywny. Unia T jest sprawdzana element po elemencie:
type ToArray<T> = T extends any ? T[] : never;
type Result = ToArray<string | number>;
// Result = string[] | number[] (not (string | number)[])
Dlatego filtrowanie unionów jest szybkie:
type ExcludeString<T> = T extends string ? never : T;
type NoStrings = ExcludeString<string | number | boolean>;
// NoStrings = number | boolean
never nie znika z unionów — to ta sama zasada, która leży u podstaw wbudowanej funkcji Exclude<T, U>.
2. Wyodrębnianie kształtów za pomocą infer
infer występuje wyłącznie w klauzuli extends. Deklaruje on miejsce zastępcze, które TypeScript wypełnia na podstawie dopasowanego kształtu. To klasyczne wyodrębnianie typu zwracanego:
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
function getUser() {
return { id: 1, name: "Oussama" };
}type User = MyReturnType<typeof getUser>;
// User = { id: number; name: string }
Kroki: dopasować typeof getUser do (...args: any[]) => infer R, powiązać R z rzeczywistym wartością zwracaną i ustalić ją jako R.
infer może być również użyty w innych pozycjach:
// Extract the element type of an array
type ElementOf<T> = T extends (infer U)[] ? U : never;
type Item = ElementOf<string[]>; // string
// Extract the resolved type of a Promise
type Awaited2<T> = T extends Promise<infer U> ? U : T;
type Data = Awaited2<Promise<{ status: number }>>;
// Data = { status: number }// Extract the first argument of a function
type FirstArg<T> = T extends (arg: infer A, ...rest: any[]) => any ? A : never;
type Arg = FirstArg<(id: number, name: string) => void>; // number
Rekurencja rozpakowuje nawarstwione obietnice:
type DeepAwaited<T> = T extends Promise<infer U> ? DeepAwaited<U> : T;
type Flat = DeepAwaited<Promise<Promise<Promise<string>>>>;
// Flat = string
3. Pętlenie z użyciem keyof w typach mapowanych
keyof zwraca zbiór kluczy:
interface User {
id: number;
name: string;
email: string;
}
type UserKeys = keyof User; // "id" | "name" | "email"
Typy mapowane iterują po tym zbiorze jak typowa instrukcja for...in:
type Readonly2<T> = {
[K in keyof T]: T[K];
};
Dodaj modyfikatory dla wersji opcjonalnych lub tylko do odczytu:
// Make every property optional
type Optional<T> = {
[K in keyof T]?: T[K];
};
// Make every property readonly
type ReadonlyAll<T> = {
readonly [K in keyof T]: T[K];
};// Remove readonly / optional with a minus modifier
type Mutable<T> = {
-readonly [K in keyof T]-?: T[K];
};
Zmieniaj wartości podczas pętlenia:
type Stringify<T> = {
[K in keyof T]: string;
};
type StringifiedUser = Stringify<User>;
// { id: string; name: string; email: string }
Łącz z warunkami:
type NullableStrings<T> = {
[K in keyof T]: T[K] extends string ? T[K] | null : T[K];
};
type Result2 = NullableStrings<User>;
// { id: number; name: string | null; email: string | null }
4. Przypisywanie nowych kluczy za pomocą as
Wewnątrz typu mapowanego as oblicza nową nazwę klucza dla każdej właściwości:
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
/*
{
getId: () => number;
getName: () => string;
getEmail: () => string;
}
*/
[K in keyof T] służy do iteracji; literał szablonowy tworzy klucze w stylu getName; T[K] staje się typem zwracanym przez funkcję dostępu.
Użyj as never, aby usunąć klucze podczas pętlenia:
type OmitByType<T, U> = {
[K in keyof T as T[K] extends U ? never : K]: T[K];
};
interface Mixed {
id: number;
name: string;
active: boolean;
}type OnlyNonBoolean = OmitByType<Mixed, boolean>;
// { id: number; name: string }
To extends wewnątrz wzorca as to sposób tworzenia wariantów Pick/Omit opartych na wartościach.
Wszystkie cztery razem
Rzeczywista kombinacja: walidatory dla pól danych, pomijanie właściwości funkcji:
type Validators<T> = {
[K in keyof T as T[K] extends Function ? never : `validate${Capitalize<string & K>}`]:
(value: T[K]) => value is T[K];
};
interface ApiResponse {
id: number;
email: string;
active: boolean;
refresh: () => void;
}type ResponseValidators = Validators<ApiResponse>;
/*
{
validateId: (value: number) => value is number;
validateEmail: (value: string) => value is string;
validateActive: (value: boolean) => value is boolean;
}
*/
Rozbicie: keyof dostarcza klucze; pętla mapowania odwiedza każdy z nich; as zmienia nazwy lub odrzuca elementy za pomocą never, gdy T[K] extends Function; extends decyduje o zachowaniu lub usunięciu; infer pojawiłby się przy wyodrębnianiu zagnieżdżonych typów argumentów.
Szybki przewodnik
| Cecha | Rola | Gdzie |
|---|---|---|
extends | Warunek na poziomie typu | Porównywanie typów |
infer | Zachowanie dopasowanego elementu | Tylko wewnątrz extends |
[K in keyof T]asasGdy kliknie się w te elementy, złożone struktury przestaną wymuszać użycie typu any. Można odbudować funkcje Pick, Omit, Record oraz ReturnType od zera — wszystkie powyższe funkcje będą dostępne — a narzędzia na poziomie typów staną się praktyczne, a nie tajemnicze. Autorzy bibliotek polegają na tych samych czterech mechanizmach; czytanie ich plików .d.ts staje się znacznie łatwiejsze po tygodniu ręcznej odbudowy tych narzędzi.
Szlak ćwiczeń
Odbuduj Exclude za pomocą warunków dystrybutywnych, ReturnType z użyciem infer, Partial/Readonly z modyfikatorami mapowanymi, a także własną funkcję OmitByType z parametrem as never. Te cztery ćwiczenia wykorzystują wszystkie funkcje opisane w tym przewodniku. Po ich wykonaniu czytanie plików .d.ts biblioteki przestaje przypominać kod golf i staje się zwykłym TypeScriptem – tylko na poziomie typów.
Gdy błąd typu przeradza się w serię warunków, zastosuj metodę podziału na części: tymczasowo zastąp typ mapowany konkretnym typem obiektu lub zamiń infer R na wyraźną adnotację, aż błędna ścieżka stanie się oczywista. Debugowanie na poziomie typów to nadal debugowanie; stosuje się w tym przypadku tę samą strategię podziału i pokonywania problemów.
Lepiej używać nazwanych aliasów zamiast ogromnych, jednowierszowych rozwiązań, aby przyszli czytelnicy mogli zrozumieć intencję. Eksportuj te narzędzia, których aplikacja faktycznie ponownie wykorzystuje; pozostaw hipotetyczne funkcje w środowisku izolowanym, dopóki nie pojawi się kolejne miejsce ich wywołania. Dzięki temu struktura typów będzie tak celowa, jak API w czasie wykonywania.
Pozycje pokrewne
- TypeScript nie jest wolny — to nadmiernie skomplikowane typy są — Używanie generyków, przedwczesne stosowanie zasady DRY w typach, stan flagi opcjonalnej oraz przebiegłe rozwiązania na poziomie typów obniżają wydajność. Lepiej stosować proste interfejsy, zespoły typów rozróżnionych oraz umiarkowane wykorzystanie narzędzia tsc.