Strona główna / Artykuły / Zachowania TypeScript, które zaskakują doświadczonych programistów, i dlaczego

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.

4295 słów

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.
  • Zamiast any należy preferować unknown, a zamiast as – satisfies, gdy celem jest sprawdzanie, a nie przejęcie kontroli.
  • Należy używać zjednoczeń dyskryminowanych oraz 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

  • Dziesięć patternów TypeScript, które przekształcają błędy w trakcie ruchu w błędy kompilacji — Poznaj dziesięć technik TypeScript, od zjednoczeń dyskryminowanych i funkcji satisfies po typy branded oraz infer, które pozwalają kompilatorowi odrzucić nieważne stany przed wypuszczeniem kodu.
  • Co kompilator TypeScript wychwytuje, a co JavaScript pozwala przejść bezkarnie — Porównaj JavaScript i TypeScript side by side: inferencję typów, adnotacje, wartości pierwotne, typ any, zjednoczenia oraz funkcje typowane, a także to, co faktycznie sygnalizuje i wyświetla tsc.