Zachowania TypeScript, które zaskakują doświadczonych programistów, i dlaczego
Typowanie strukturalne, sprawdzanie nadmiarowych właściwości, użycie klucza as const, typy warunkowe i mapowane, a także zasady projektowania, które przekształcają je w bezpieczniejszy kod.
Większość programistów zaczyna od traktowania TypeScript jako JavaScript z adnotacjami, a potem napotyka zachowania, które nie pasują do tego obrazu: obiekt z dodatkowymi polami jest akceptowany w jednym miejscu, a odrzucany w innym, stwierdzenie typu niczego nie „przekształca”, a readonly nadal pozwala na zmianę zagnieżdżonej wartości. Pod podstawowymi adnotacjami kryje się język na poziomie typów z własnymi regułami dotyczącymi kompatybilności, inferencji i obliczeń. Ten przewodnik wyjaśnia te zaskoczenia po kolei – od usuwania typów w czasie wykonywania i typowania strukturalnego, przez infer, literalne typy szablonowe i satisfies, aż po przekształcenie ich w praktyczne zasady projektowe, które można zastosować w rzeczywistym kodzie.
Typy przestają istnieć, gdy kod się uruchamia
Oto interfejs oraz obiekt z nim adnotowany:
interface User {
id: number;
name: string;
}
const user: User = {
id: 1,
name: "Lakhveer"
};
Może się wydawać, że uruchomiony program wie, iż user to obiekt typu User. Tak nie jest. Podczas kompilacji interfejs zostaje usunięty, a to, co pozostaje, polega zasadniczo na tym:
const user = {
id: 1,
name: "Lakhveer"
};
W czasie wykonywania nie istnieje żadna wartość typu User. TypeScript najpierw przeprowadza analizę statyczną, a następnie generuje JavaScript – tylko ten JavaScript trafia do silnika:
TypeScript
↓
Type Checking
↓
JavaScript Generation
↓
Browser / Node.js
Typy służą kompilatorowi jako informacje; nie są to obiekty w czasie wykonywania. Praktyczną konsekwencją jest to, że TypeScript nigdy nie weryfikuje danych pochodzących z zewnątrz programu. Poniższa adnotacja jedynie potwierdza to, co zwraca serwer; nic tego nie sprawdza:
const response: User = await fetch("/api/user")
.then(res => res.json());
Dla danych zewnętrznych potrzebna jest weryfikacja w czasie wykonywania. Biblioteki do definiowania schematów, takie jak Zod, opisują ich strukturę raz i sprawdzają ją po przybyciu danych:
const UserSchema = z.object({
id: z.number(),
name: z.string()
});
const user = UserSchema.parse(data);
Korzystny sposób na zapamiętanie tego rozróżnienia: kompilator chroni twój kod, natomiast walidacja w czasie wykonywania chroni twoją aplikację. Przewodnik bloga na temat dzielenia się jednym schematem Zod pomiędzy frontendem React a backendem Node pokazuje, jak zastosować to rozwiązanie po obu stronach.
any, unknown i ciężar dowodu
any wyłącza sprawdzanie
Z użyciem any każda z tych bezsensownych operacji kompiluje się bez żadnych problemów:
let value: any = "hello";
value.foo.bar.baz();
value();
value.notARealProperty;
any faktycznie mówi kompilatorowi, aby mu zaufał i przestał sprawdzać. Dlatego parametr typowany w ten sposób po cichu odrzuca większość zalet TypeScript:
function processUser(user: any) {
console.log(user.name);
}
unknown wymaga dowodów
unknown również przyjmuje każdą wartość:
let value: unknown = "hello";
Jednak bezpośrednie użycie go kończy się niepowodzeniem:
value.foo;
Najpierw trzeba to zawęzić, na przykład do ciągu znaków:
if (typeof value === "string") {
console.log(value.toUpperCase());
}
lub do liczby:
if (typeof value === "number") {
console.log(value.toFixed(2));
}
Różnicę w podejściu można łatwo podsumować:
any
↓
"Trust me"
unknown
↓
"Prove it first"
Zatem gdy naprawdę nie znasz typu wartości, skorzystaj z
unknown
Zamiast tego
any
i pozwól kompilatorowi zmusić cię do udowodnienia, co masz, zanim to użyjesz.
Kompatybilność dotyczy struktury, a nie nazw
Rozwojowcy pochodzący z Java, C# lub C++ często są zaskoczeni, że taka przypisanie jest dozwolone:
interface User {
name: string;
}
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
TypeScript wykorzystuje typowanie strukturalne: kompatybilność zależy od tego, jakie właściwości ma wartość, a nie od tego, jako co została zadeklarowana. User potrzebuje tylko tego:
name: string
A employee ma to, plus jeszcze inne. Rozumowanie stosowane przez kompilator wygląda w ten sposób:
User requires:
name: string
employee has:
name: string
salary: number
Therefore:
employee satisfies User
lub, jako przepływ decyzji:
Required properties
↓
Does object contain them?
↓
Yes
↓
Compatible
Nadmierne sprawdzanie właściwości dotyczy świeżych literałów
A teraz zwrot akcji. Dodawanie dodatkowego pola bezpośrednio do literału obiektu jest odrzucane:
interface User {
name: string;
}
const user: User = {
name: "Lakhveer",
salary: 100000
};
z błędem w stylu:
Object literal may only specify known properties
Jednak przypisanie tych samych danych za pośrednictwem zmiennej pośredniej jest dozwolone:
const employee = {
name: "Lakhveer",
salary: 100000
};
const user: User = employee;
Powodem jest to, że TypeScript przeprowadza nadmierne sprawdzanie właściwości świeżych literałów obiektów, tych napisanych bezpośrednio w momencie przypisywania, jako zabezpieczenie przed błędami pisowni. To sprawdzenie jest oddzielne od kompatybilności strukturalnej. Dlatego stwierdzenie „TypeScript odrzuca dodatkowe właściwości” odnosi się tylko do literałów; gdy obiekt już przeszedł przez zmienną, dodatkowe pola nie stanowią problemu.
Typy literałów i pochodne unie
Dokładne wartości zamiast szerokich typów
Zmienna może być ograniczona do konkretnych wartości:
let direction: "left" | "right";
direction = "left";
Zatem ta przypisanie zawodzi:
direction = "up";
Deklaracja tego nie mówi
direction: string
Mówi to
direction must be EXACTLY:
"left"
OR
"right"
Taka precyzja znacznie poprawia API. Funkcja implementacji może przyjmować tylko znane środowiska:
type Environment =
| "development"
| "staging"
| "production";
function deploy(env: Environment) {
// ...
}
A błąd pisowni staje się błędem kompilacji zamiast nieudanej implementacji:
deploy("testing");
as const zmienia to, co jest wywnioskowywane
Zwykły literał tablicy znaków:
const colors = ["red", "blue", "green"];
jest wywnioskowywany jako
string[]
Dodanie as const:
const colors = ["red", "blue", "green"] as const;
wytworza zamiast tego tuple jedynie do odczytu typów literałowych:
readonly ["red", "blue", "green"]
Z tej tablicy można wywnioskować typ union poprzez indeksowanie za pomocą number:
type Color = typeof colors[number];
co daje:
type Color = "red" | "blue" | "green";
To eliminuje powszechną duplikację. Bez tego trzeba utrzymywać zbiór i tablicę, które muszą być ręcznie synchronizowane:
type Color = "red" | "blue" | "green";
const colors: Color[] = [
"red",
"blue",
"green"
];
Z jego użyciem tablica staje się jedynym źródłem prawdy, a typ jest określany automatycznie:
const colors = [
"red",
"blue",
"green"
] as const;
type Color = typeof colors[number];
Zasada ta jest ogólna: gdy system typów może wywnioskować informacje, nie należy ich pisać dwukrotnie.
Słowa kluczowe działające na poziomie typów
typeof ma dwa zadania
W JavaScript,
typeof value
jest operatorem w czasie wykonywania. Na przykład,
typeof "hello";
da jako wynik
"string"
W kontekście typów TypeScript ponownie wykorzystuje to słowo kluczowe do określenia statycznego typu zmiennej:
const user = {
id: 1,
name: "Lakhveer"
};
type User = typeof user;
Tutaj
User
staje się
{
id: number;
name: string;
}
To samo słowo kluczowe, dwa konteksty:
Runtime:
typeof value
Type system:
typeof variable
keyof przekształca klucze w zbiór
Danej interfejsowi,
interface User {
id: number;
name: string;
email: string;
}
ten typ
type UserKeys = keyof User;
jest
"id" | "name" | "email"
W połączeniu z generykami umożliwia to napisanie dostępu do właściwości, który przyjmuje tylko rzeczywiste klucze:
function getValue<T, K extends keyof T>(
object: T,
key: K
) {
return object[key];
}
Wywołanie go z istniejącym kluczem działa poprawnie:
const user = {
id: 1,
name: "Lakhveer"
};
getValue(user, "name");
podczas gdy brakujący klucz jest odrzucany w czasie kompilacji:
getValue(user, "salary");
Typ zwracany jest również precyzyjny: T[K] odpowiada typowi danej właściwości.
Generyki łączą ze sobą wartości
Klasyczny generyk po prostu zwraca to, co otrzymał:
function identity<T>(value: T): T {
return value;
}
Generyki stają się ciekawsze, gdy łączą ze sobą kilka wartości. W tym przypadku oba argumenty muszą mieć ten sam typ:
function pair<T>(first: T, second: T): [T, T] {
return [first, second];
}
dlatego to wywołanie jest akceptowane:
pair(10, 20);
ale ten przypadek zawodzi, ponieważ T jest wywnioskowany z pierwszego argumentu jako number, a do niego nie można przypisać ciągu znaków:
pair(10, "hello");
Generiki mogą również w sposób uczciwy łączyć dane wejściowe z wyjściowymi, w tym w przypadku pustym:
function first<T>(items: T[]): T | undefined {
return items[0];
}
Dla wywołania takiego jak
const numbers = first([1, 2, 3]);
kompilator raportuje wynik jako
number | undefined
Obliczanie typów
Typy warunkowe to if na poziomie typów
Typ warunkowy wybiera między dwoma wynikami na podstawie sprawdzenia:
type IsString<T> =
T extends string
? true
: false;
Zatem
type A = IsString<string>;
rozwija się do
true
i
type B = IsString<number>;
rozwija się do
false
Koncepcyjnie piszesz to samo, tylko że działa to w kompilatorze, a nie w twoim programie:
if T is string
return true
else
return false
infer wyodrębnia części typu
Wewnątrz typu warunkowego infer wprowadza zmienną typu, którą TypeScript wypełnia poprzez dopasowanie. To ponowna implementacja wbudowanego ReturnType:
type ReturnTypeOf<T> =
T extends (...args: any[]) => infer R
? R
: never;
Gdy zostanie zastosowany do rzeczywistej funkcji, wyodrębnia typ zwracanego obiektu:
function getUser() {
return {
id: 1,
name: "Lakhveer"
};
}
type User = ReturnTypeOf<typeof getUser>;
Model mentalny to dopasowanie wzorca:
Function
↓
infer R
↓
Extract return type
Wiele standardowych typów pomocniczych jest budowanych dokładnie w ten sposób.
Typy mapowane przekształcają każdą właściwość
Począwszy od interfejsu,
interface User {
id: number;
name: string;
email: string;
}
typ mapowany iteruje nad swoimi kluczami, aby stworzyć wersję tylko do odczytu:
type ReadonlyUser = {
readonly [K in keyof User]: User[K];
};
lub wersję opcjonalną:
type OptionalUser = {
[K in keyof User]?: User[K];
};
Zamiast ręcznie przepisywać każde pole:
id?: number;
name?: string;
email?: string;
Typy pomocnicze są budowane z tych elementów
TypeScript dostarcza bibliotekę takich narzędzi:
Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>
Exclude<T, U>
Extract<T, U>
NonNullable<T>
ReturnType<T>
Parameters<T>
Przy danym modelu w tym stylu,
interface User {
id: number;
name: string;
email: string;
}
Pick zachowuje wybrane klucze:
type UserPreview = Pick<User, "id" | "name">;
wytwarzanie
{
id: number;
name: string;
}
a Omit je usuwa:
type UserWithoutEmail = Omit<User, "email">;
Aby zapoznać się z pełnym zestawem, sprawdź przewodnik na blogu dotyczący wbudowanych typów pomocniczych TypeScript.
never, zawężanie i mechanizmy ochronne
never dowodzi, że obsłużyłeś wszystko
never to typ wartości, która nie może istnieć, np. wynik funkcji, która zawsze rzuca błędem:
function fail(message: string): never {
throw new Error(message);
}
Jego prawdziwa moc objawia się w sprawdzaniach wyczerpującości. Weźmy unię stanów:
type Status =
| "loading"
| "success"
| "error";
oraz switch, w którym gałąź default przekazuje wartość do funkcji, która przyjmuje tylko never:
function handleStatus(status: Status) {
switch (status) {
case "loading":
return "Loading";
case "success":
return "Success";
case "error":
return "Error";
default:
return assertNever(status);
}
}
function assertNever(value: never): never {
throw new Error("Unexpected value: " + value);
}
Gdy obsłużono wszystkie przypadki, status zostaje zawężony do never zanim dotrze do bloku default, dzięki czemu możliwe jest sprawdzenie typu. Załóżmy teraz, że ktoś rozszerzy tę unię:
type Status =
| "loading"
| "success"
| "error"
| "cancelled";
Nieobsłużony przypadek "cancelled" trafia do funkcji assertNever; nie można go przypisać do wartości never, a kompilator wskazuje na każdy przypadek switch, który wymaga aktualizacji.
Zawężanie następuje zgodnie z przepływem sterowania
Kompilator śledzi, jak sprawdzenia wpływają na to, jaką wartość może mieć zmienna:
function print(value: string | number) {
if (typeof value === "string") {
console.log(value.toUpperCase());
} else {
console.log(value.toFixed(2));
}
}
W pierwszym gałęziu,
value
wiadomo, że jest to
string
a w drugim gałęziu jest to
number
Własne strażniki typów
Można nauczyć kompilatora rozpoznawania własnych typów za pomocą funkcji, której typ zwracany to predykat typu, np. value is User:
interface User {
name: string;
}
function isUser(value: unknown): value is User {
return (
typeof value === "object" &&
value !== null &&
"name" in value
);
}
Po pomyślnym przejściu sprawdzenia,
const data: unknown = getData();
if (isUser(data)) {
console.log(data.name);
}
kompilator traktuje tę wartość jako
data: User
znajdującą się wewnątrz bloku. Należy pamiętać, że kompilator w pełni ufa predykatowi. To sprawdzenie jedynie weryfikuje, czy name istnieje, a nie to, czy jest to ciąg znaków, więc niedbale zaprojektowane sprawdzenie jest w praktyce niezweryfikowaną twierdzeniem.
Modelowanie stanu za pomocą zjednoczeń dyskryminowanych
Powszechnym, lecz słabym podejściem jest umieszczanie wszystkich możliwości w jednym obiekcie z polami opcjonalnymi:
interface State {
status: string;
data?: User;
error?: string;
}
Zjednoczenie dyskryminowane modeluje każdy stan oddzielnie, oznaczony status:
type State =
| {
status: "loading";
}
| {
status: "success";
data: User;
}
| {
status: "error";
error: string;
};
Przełączanie się na odpowiedni tag ogranicza każdą gałąź wyłącznie do tych pól, które tam istnieją:
function render(state: State) {
switch (state.status) {
case "loading":
return "Loading...";
case "success":
return state.data.name;
case "error":
return state.error;
}
}
To eliminuje sprzeczności takie jak
status = success
error = "Something went wrong"
co wersja luźna chętnie umożliwia. Zasadą przewodnią jest modelowanie ważnych stanów, zamiast dopuszczania nieważnych i sprawdzania ich wszędzie.
wypełnia warunki bez przepisywania
satisfies sprawdza, czy wyrażenie odpowiada określonemu typowi, zachowując przy tym własny typ wywnioskowany z wyrażenia:
const config = {
port: 3000,
host: "localhost"
} satisfies {
port: number;
host: string;
};
To jest idealne dla obiektów konfiguracyjnych, gdzie chce się przeprowadzić sprawdzenie, ale także zachować wartości literowe oraz dokładne klucze. Porównaj to z twierdzeniem:
const config = {...} as Config;
as mówi kompilatorowi, aby traktował wartość jako ten typ, a on zaakceptuje ją na podstawie twoich słów. satisfies natomiast prosi kompilatora o potwierdzenie zgodności. Gdy twoim celem jest walidacja, a nie przepisanie mechanizmu sprawdzania, lepiej użyć
satisfies
zamiast
as
Ograniczenia, które wprowadzają w błąd
readonly ma ograniczony zasięg
Rozważmy typ z właściwościami tylko do odczytu, z których jedna jest obiektem:
type User = {
readonly name: string;
readonly address: {
city: string;
};
};
Przeznaczenie nowej wartości dla właściwości najwyższego poziomu jest zabronione:
user.name = "New Name";
ale zmiana pola wewnątrz zagnieżdżonego obiektu nadal jest dozwolona:
user.address.city = "Indore";
readonly dotyczy tylko tej właściwości, którą oznacza, a nie w sposób rekurencyjny. Głęboka niezmienność wymaga rekurencyjnego typu mapowanego lub mechanizmu w czasie wykonywania, przy czym należy pamiętać, że Object.freeze również ma ograniczony zasięg.
Aksjeracje nie konwertują wartości
Ta podwójna aksjeracja kompiluje się:
const value = "123" as unknown as number;
ale nic nie zostaje przekonwertowane. W czasie wykonywania,
typeof value
nadal jest to raportowane
string
Jeśli potrzebujesz liczby, przekonwertuj ją wyraźnie:
const value = Number("123");
Aksjeracje zmieniają to, w co wierzy kompilator, a nie samą wartość.
Opcjonalne nie zawsze jest to samo co nieskończone
Atrybut opcjonalny:
interface User {
name?: string;
}
zazwyczaj oznacza, że klucz może być brakujący, więc obiekt jest pusty
{}
jest ważny, podobnie jak
{
name: "Lakhveer"
}
Ale to, czy jakaś wartość jest wyraźna
{
name: undefined
}
jest dozwolona, zależy od konfiguracji. Włączenie
{
"exactOptionalPropertyTypes": true
}
pozwala kompilatorowi odróżnić te dwa przypadki. Ma to znaczenie w API, gdzie
property missing
i
property explicitly undefined
oznaczają różne rzeczy, na przykład w żądaniu PATCH – brakujące pole oznacza „zostawi bez zmian”, natomiast wyraźna wartość oznacza „usuń je”.
Indeksowanie może ukrywać nieskończone wartości
TypeScript celowo nie próbuje zapobiegać każdemu błędowi w czasie wykonywania. Czytanie poza koniec tablicy:
const numbers = [1, 2, 3];
const value = numbers[100];
przy domyślnych ustawieniach jest traktowane tak, jakby zawsze istniał tam numer. Włączenie
{
"noUncheckedIndexedAccess": true
}
umożliwia dostęp
numbers[100]
raportuj jako
number | undefined
co zmusza do obsługi przypadku braku danych.
Typy ciągów znakowych i relacyjne
Typy literałów szablonowych
TypeScript może tworzyć typy ciągów znakowych na podstawie innych typów ciągów znakowych:
type EventName =
`user:${"created" | "updated" | "deleted"}`;
co rozszerza się do
"user:created"
"user:updated"
"user:deleted"
Ta sama technika może służyć do opisu tras:
type HttpMethod = "GET" | "POST";
type Endpoint =
`${HttpMethod} /users`;
zatem dopuszczalne wartości to
"GET /users"
"POST /users"
Łączenie elementów w wyliczane API
Te funkcje łączą się ze sobą:
keyof
typeof
conditional types
mapped types
template literals
infer
generics
Zacznij od mapy od nazw zdarzeń do typów danych:
type EventMap = {
userCreated: {
id: number;
};
userDeleted: {
id: number;
};
};
Generyczny emiter może następnie powiązać każdą nazwę zdarzenia z jego danymi za pomocą keyof i dostępu indeksowego:
class EventEmitter<Events extends Record<string, unknown>> {
on<K extends keyof Events>(
event: K,
callback: (payload: Events[K]) => void
) {
// ...
}
emit<K extends keyof Events>(
event: K,
payload: Events[K]
) {
// ...
}
}
Wysyłanie znanego zdarzenia z odpowiednimi danymi kompiluje się pomyślnie:
const emitter =
new EventEmitter<EventMap>();
emitter.emit("userCreated", {
id: 1
});
podczas gdy błędne dane są odrzucane:
emitter.emit("userCreated", {
name: "Lakhveer"
});
Kompilator teraz rozumie związek pomiędzy nazwą wydarzenia a danymi, które muszą mu towarzyszyć.
Rozpocznij w trybie ścisłym
Profesjonalny projekt powinien zazwyczaj rozpoczynać się od:
{
"compilerOptions": {
"strict": true
}
}
To pojedyncze flagi umożliwiają serię sprawdzeń, w tym:
strictNullChecks
noImplicitAny
strictFunctionTypes
strictPropertyInitialization
useUnknownInCatchVariables
Włącza również takie opcje jak strictBindCallApply i noImplicitThis. Dodawanie zabezpieczeń dopiero po pojawieniu się błędów jest znacznie droższe niż pozwalanie kompilatorowi pełnić rolę pierwszej linii obrony.
Zrób tak, by nieprawidłowe stany nie mogły istnieć
Rozważmy cykl życia żądania modelowany jako unia:
type RequestState =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Porównaj to z projektem opartym na flagach i opcjonalnych polach:
interface RequestState {
loading: boolean;
data?: User;
error?: string;
}
Drugi podejście pozwala na absurdalne sytuacje takie jak
{
loading: true,
data: user,
error: "Something failed"
}
podczas gdy pierwszy sprawia, że takie kombinacje stają się niemożliwe do utworzenia. Jeśli istnieje jedna zasada projektowania, którą można przejąć z TypeScript, to właśnie ta. Aby zapoznać się ze szczegółowym omówieniem, zobacz modelowanie domen w TypeScript poza podstawowymi adnotacjami.
Zasady projektowania dla TypeScript w produkcji
Znajomość funkcjonalności nie jest tym samym co umiejętność dobrego projektowania z ich wykorzystaniem. Poniższe zasady dotyczą sposobu kształtowania systemów.
Traktuj any jako ostateczność
Zamiast
function process(data: any) {
// ...
}
woląć
function process(data: unknown) {
// validate/narrow first
}
a gdy znasz strukturę, tym lepiej
function process(data: User) {
// ...
}
Używaj any tylko wtedy, gdy dokładnie rozumiesz, co rezygnujesz.
Pozwól inferencji zająć się oczywistymi przypadkami
Takie adnotacje dodają szumu:
const name: string = "Lakhveer";
const age: number = 28;
Kompilator już wie:
const name = "Lakhveer";
const age = 28;
Zachowuj wyraźne typy w miejscach, gdzie dokumentują one umowę.
Niech typy przekazują intencję
Pusta strona tekstowa mówi niewiele:
function process(value: string) {}
Typ o nazwie mówi, co oznacza wartość:
type UserId = string;
function processUser(userId: UserId) {}
Jedna uwaga: alias taki jak UserId = string dokumentuje intencję, ale nie zapobiega przekazywaniu ProductId tam, gdzie oczekiwany jest UserId, ponieważ oba są po prostu stringami. Jeśli ich pomieszanie stanowi rzeczywiste zagrożenie, typ z nazwą dodaje takie ograniczenie.
Zachowuj typy blisko domeny
Podpis zbudowany z surowych stringów:
function createOrder(
userId: string,
productId: string,
status: string
) {}
staje się znacznie jaśniejszy dzięki typom domenowym:
type OrderStatus =
| "pending"
| "paid"
| "cancelled";
function createOrder(
userId: UserId,
productId: ProductId,
status: OrderStatus
) {}
Teraz kompilator rozumie twoją specyficzną terminologię biznesową, a nie tylko prymitywne struktury.
Niech zamiast flag boolowskich będą unie
Niezależne wartości booleanowe umożliwiają niemożliwe kombinacje:
interface State {
loading: boolean;
success: boolean;
error: boolean;
}
Unia pozwala na jedno określone stanu naraz:
type State =
| "loading"
| "success"
| "error";
Przejdź na unię z rozróżnieniem, gdy stan potrzebuje własnych danych.
Kompilator nie może zagwarantować niczego, co pochodzi z zewnątrz:
API
Database
User input
Environment variables
Files
Third-party services
JSON
Local storage
Traktuj wszystko to jako niezaufane i kieruj je przez jeden pipeline:
External data
↓
Runtime validation
↓
Trusted typed data
↓
Application logic
Dane po walidacji stają się zaufanymi danymi typowanymi, i tylko one docierają do logiki aplikacji.
Można tworzyć niezwykle skomplikowane typy, ale sygnatura taka jak
type Something<T, U, V, X extends ...> = ...
którą nikt w zespole nie potrafi wyjaśnić po sześciu miesiącach, stanowi dług techniczny. Typy powinny ułatwiać zrozumienie kodu, a nie pokazywać pomysłowości.
Niełatwo jest popełnić błąd przy użyciu kilku flag pozycyjnych:
createUser(
"Lakhveer",
"admin",
true,
false,
undefined
);
Obiekt opcji o określonym typie jest samoodnoszący i zapewnia znacznie lepsze uzupełnianie tekstu:
createUser({
name: "Lakhveer",
role: "admin",
active: true
});
Komponuj zamiast tworzyć ogromne interfejsy
Jeden interfejs z dziesiątkami pól
interface User {
// 50 properties
}
jest trudniejszy do zrozumienia niż mniejsze koncepcje połączone ze sobą:
type Identifiable = {
id: string;
};
type Timestamped = {
createdAt: Date;
updatedAt: Date;
};
type User =
Identifiable &
Timestamped & {
name: string;
};
Zrób z kompilatora część swojej strategii testowania
Typy nie zastępują testów, ale eliminują całe kategorie błędów przed ich uruchomieniem. Biorąc pod uwagę
type PaymentStatus =
| "pending"
| "paid"
| "failed";
dodanie nowego elementu, na przykład
"refunded"
pozwoli, w połączeniu z kontrolami kompletności, odkryć wszystkie miejsca, w których zapomniano się z nim uporać.
Rozdzielaj w umyśle czas kompilacji i działania programu
Zadaj sobie pytanie, w jakiej warstwie pracujesz. To dotyczy wyłącznie czasu kompilacji:
interface User {
id: number;
}
To jest sprawdzenie w czasie wykonywania:
if (typeof value === "object") {
}
A to jest walidacja danych zewnętrznych w czasie wykonywania:
UserSchema.parse(data);
Czytaj generowany JavaScript
Gdy zachowanie jest mylące, zapytaj się, jakiego JavaScriptu kod się staje. Znajomość obu warstw wyjaśnia większość niespodzianek.
Znaj umiejętności JavaScripta na głębokim poziomie
TypeScript opiera się na JavaScriptu, więc podstawy wciąż są ważne:
Closures
Promises
Event Loop
Prototypes
this
Modules
Destructuring
Async/Await
Objects
Arrays
Functions
Hoisting
Scopes
Traktuj tsconfig.json z rozwagą
Nie kopiuj konfiguracji ślepo. Zrozum, co daje i kosztuje każda opcja:
{
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
Każdy flag zmienia równowagę pomiędzy bezpieczeństwem a wygodą; noImplicitOverride, na przykład, wymaga użycia override dla każdej metody, która zastępuje metodę z klasy bazowej.
Kierunkowy model mentalny
Pomaga wyobrazić sobie TypeScript jako dwie równoległe warstwy: JavaScript w czasie wykonywania oraz system typów w czasie kompilacji, przy czym funkcje na poziomie typów budują się jedna na drugiej:
TypeScript
│
┌────────────┴────────────┐
│ │
JavaScript Type System
│ │
Runtime Behavior Compile-Time Safety
│ │
Browser / Node Type Relationships
│
┌──────────┼──────────┐
│ │ │
Generics Unions Inference
│ │ │
keyof never conditional
│ │ │
mapped guards infer
│ │ │
└──────────┴──────────┘
Patrząc w ten sposób, TypeScript przestaje wyglądać jak zbiór reguł składniowych i staje się językiem służącym do opisywania relacji między wartościami: jakie wartości są dozwolone, w jaki sposób obiekty się odnoszą, jakie stany mogą wystąpić, jakie funkcje przyjmują i zwracają dane oraz które przypadki są jeszcze nierozwiązane.
Główne wnioski
Najważniejsze funkcje, które warto opanować, to nie te najbardziej efektowne:
Generics
Unions
Narrowing
Inference
keyof
typeof
Mapped Types
Conditional Types
infer
Discriminated Unions
never
unknown
satisfies
Template Literal Types
- Typy są usuwane w czasie wykonywania, więc dane zewnętrzne zawsze wymagają weryfikacji.
- Kompatybilność jest strukturalna, przy czym dodatkowa kontrola odbywa się tylko w przypadku nowych literali obiektowych.
as const, typeof, keyof, typy warunkowe i mapowane pozwalają na wyprowadzanie typów zamiast ich kopiowania.any należy preferować unknown, a zamiast as – satisfies, gdy celem jest sprawdzanie, a nie przejęcie kontroli.never, aby kompilator mógł określić, czy dany stan może faktycznie wystąpić.Celem nie są najbardziej zaawansowane typy, lecz kod, w którym kompilator odpowiada na pytanie „czy ten stan może się zdarzyć?” jeszcze przed uruchomieniem programu. Używaj TypeScript do projektowania bezpieczniejszego kodu, a nie tylko do opisywania tego, co już napisałeś.
Literatura pokrewna
- Sześć technik TypeScript, które przekształcają typy w skuteczną prewencję błędów — Dowiedz się, jak funkcje satisfies, związki oznaczone tagami, mechanizm never checks, typy nieznane, typy pochodne oraz unikalne identyfikatory pomagają TypeScript wykrywać prawdziwe błędy już na etapie kompilacji, a nie w środowisku produkcyjnym.
- Modelowanie domen w TypeScript: Poza podstawowymi adnotacjami typów — Poznaj praktyczne przyzwyczajenia w pracy z TypeScriptem – od rozróżniania typów unknown i any po związki dyskryminowane oraz funkcję satisfies – które pomagają modelować poprawne stany danych zamiast jedynie je oznaczać.