Strona główna / Artykuły / Dziesięć nawyków w TypeScript, które sprawiają, że duże bazy kodu są czytelne i bezpieczne

Dziesięć nawyków w TypeScript, które sprawiają, że duże bazy kodu są czytelne i bezpieczne

Naucz się dziesięciu praktycznych nawyków w TypeScript, od znaczących generatorów i zawężania typów po kompleksowe sprawdzanie, a także użycie właściwości readonly oraz surowych ustawień tsconfig, które zapewniają utrzymaność rosnących baz kodu.

2557 słów

Większość problemów z TypeScriptem w rozwijającej się bazie kodu nie ma nic wspólnego z brakiem wiedzy na temat tego, czym są typy ogólne lub warunkowe. Pochodzą one z codziennych decyzji: nadmiernie skomplikowanych abstrakcji, typów przyjmujących zbyt wiele danych, słowa as używanego wszędzie, umów zdefiniowanych dwukrotnie, nieczytelnych sygnatur typów ogólnych, funkcji, których argumenty nie mają żadnego znaczenia w miejscu wywołania, typów rozrzuconych w przypadkowych plikach oraz kompilatora skonfigurowanego zbyt luźno, by wykrywać to, na czym zależy zespół. W małym projekcie takie nawyki prawie nie są zauważalne; jednak przy dziesiątkach programistów i kilku latach pracy kumulują się one, tworząc „dług technologiczny”. Ten przewodnik przedstawia dziesięć konkretnych praktyk, które sprawiają, że kod napisany w TypeScriptie jest łatwy do odczytania i modyfikacji, a także listę kontrolną, którą można wykorzystać podczas przeglądania kodu.

Jeśli chcesz najpierw przyjrzeć się aspektowi modelowania, w tym temu, jak sprawić, by nieprawidłowe stany stały się niereprezentowalne, zacznij od modelowania domen w TypeScript poza podstawowymi adnotacjami. Tutaj skupiamy się na nawykach utrzymaniowych, które wspierają dobre modele.

1. Traktuj generyki jako sposób wyrażania relacji

Generyki są zazwyczaj przedstawiane jako mechanizm ponownego użycia, i rzeczywiście nim są. Ich ważniejszym zadaniem jest jednak łączenie typów: informowanie kompilatora, że to, co wychodzi z funkcji, jest powiązane z tym, co do niej trafiło. Oto najprostszy przydatny przykład – funkcja, która zwraca pierwszy element tablicy.

function getFirst<T>(items: T[]): T | undefined {
  return items[0]
}

Parametr typu T jest pobierany z argumentu. Prześlij tablicę użytkowników:

const users: User[] = [...]

a kompilator wywnioskuje odpowiedni wynik:

const user = getFirst(users)
// User | undefined

Ta sama funkcja działa dla różnych typów elementów bez żadnej dodatkowej anotacji:

const products: Product[] = [...]

przynosząc poprawnie skategoryzowany wynik:

const product = getFirst(products)
// Product | undefined

Teraz spójrzmy, co się dzieje, jeśli usuniemy generykę i zamiast niej użyjemy unknown. Funkcja nadal działa, ale zniknie związek pomiędzy wejściem a wyjściem, a każdy wywołujący musi przekształcić lub zawęzić wynik.

function getFirst(items: unknown[]): unknown {
  return items[0]
}

Dobrym testem przed wprowadzeniem parametru typu jest nazwanie relacji, którą on zachowuje. Jeśli nie możesz określić, jaki typ wejściowy determinuje jaki typ wyjściowy, generyka prawdopodobnie nie spełnia swojej roli.

Jeden szczegół wart zauważenia: przy włączonej opcji noUncheckedIndexedAccess (opisanej w sekcji 10) kompilator sam przypisuje typ items[0] jako T | undefined, co odpowiada tutaj wyraźnemu typowi zwracanemu.

2. Unikaj przekształcania wszystkiego w elementy generyczne

Ponieważ elementy generyczne są potężne, łatwo jest ich nadużywać. Kuszące jest tworzenie sygnatur z kilkoma parametrami typu, które wzajemnie na siebie wpływają, jak pokazano poniżej. Pisząc w ten sposób, wydaje się to zaawansowane.

function processData<
  T extends Record<string, unknown>,
  K extends keyof T,
  R extends ...
>(...) {
  // ...
}

Rozwijający, który otworzy ten plik sześć miesięcy później, zazwyczaj ma inne odczucia. Każdy dodatkowy parametr typu to coś, co czytelnik musi zapamiętać. Jeśli funkcja faktycznie obsługuje tylko użytkowników, prosta sygnatura przekazuje znacznie więcej informacji:

function processUser(user: User) {
  // ...
}

Eksperckość w TypeScript nie ocenia się po tym, ile elementów systemu typów można umieścić w jednej deklaracji. Używaj elementów generycznych tylko wtedy, gdy faktycznie odzwierciedlają one rzeczywisty związek między typami, a nie dlatego, że taka jest możliwość języka.

3. Zawężaj wartości zamiast je przekształcać

Stwierdzenie typu to najszybszy sposób na uciszenie skargi:

const value = something as string

Problem polega na tym, że as nic nie sprawdza. Mówi kompilatorowi, aby porzucił swoje wątpliwości i uwierzył tobie, a jeśli się mylisz, błąd pojawia się w czasie wykonywania. Bezpieczniejszym podejściem jest udowodnienie typu za pomocą sprawdzenia w czasie wykonywania, które kompilator rozumie:

if (typeof something === 'string') {
  console.log(something.toUpperCase())
}

Wewnątrz bloku if something jest typu string, ponieważ typeof to konstrukcja zwężająca typ. W przypadku obiektów należy napisać własny mechanizm ochrony typu. Typ zwracany value is User informuje kompilatora, że wynik true oznacza, iż argument można traktować jako User.

function isUser(value: unknown): value is User {
  return (
    typeof value === 'object' &&
    value !== null &&
    'id' in value &&
    'name' in value
  )
}

Wtedy wywołujący otrzymują takie zwężanie typu bez żadnych kosztów:

if (isUser(value)) {
  console.log(value.name)
}

Kontrast jest prosty: twierdzenie prosi kompilatora o zaufanie do Ciebie, natomiast zawężenie dostarcza dowodów. Pamiętaj, że „strażnik typu” jest tak uczciwy, jak jego treść. Przykład sprawdza, czy istnieją id i name, ale nie jaki typ danych one przechowują, więc w przypadku danych z sieci lub pamięci może być konieczne bardziej ścisłe sprawdzenia lub walidator schematu. Kompilator w pełni ufa werdyktowi tego strażnika.

4. Użyj flagi never dla niekompletnych rozgałęzień

Załóżmy, że stan jest modelowany jako unia literów tekstowych:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'

switch, który mapuje każdy stan na odpowiedni etykietę, wygląda na kompletny:

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
  }
}

Dziś jest kompletny. Problemy pojawiają się, gdy unia się rozszerza – na przykład gdy dodaje się stan anulowany:

type Status =
  | 'pending'
  | 'approved'
  | 'rejected'
  | 'cancelled'

Status może być używany w dziesiątkach miejsc, a chcesz, aby kompilator wskazał na każde z nich, które już nie obejmują wszystkich przypadków. Standardową techniką jest pomocnik sprawdzający wyczerpanie wszystkich przypadków, który przyjmuje wartość never. W gałęzi default TypeScript już wykluczył wszystkie obsłużone przypadki, więc pozostały typ powinien być never. Jeśli jakiś nowy przypadek przejdzie niezauważony, nie da się go przypisać do never, co powoduje błąd kompilacji.

function assertNever(value: never): never {
  throw new Error(`Unhandled value: ${value}`)
}

function getLabel(status: Status) {
  switch (status) {
    case 'pending':
      return 'Pending'
    case 'approved':
      return 'Approved'
    case 'rejected':
      return 'Rejected'
    default:
      return assertNever(status)
  }
}

Po dodaniu wartości 'cancelled' wywołanie assertNever(status) staje się błędem kompilacji, dopóki nie obsłużysz nowego przypadku. Definicja unii staje się jedynym źródłem prawdy, a kompilator generuje listę miejsc do aktualizacji. Jako dodatek instrukcja throw chroni program w czasie wykonywania, jeśli z zewnątrz systemu typów nadejdzie nieoczekiwana wartość.

5. Użyj readonly, aby określić, w jaki sposób należy używać danych

Typy opisują, jakie wartości są dozwolone, ale mogą również określać, w jaki sposób można nimi operować. Oznaczenie właściwości jako readonly sygnalizuje, że jest ona ustalona raz na zawsze po utworzeniu obiektu:

type User = {
  readonly id: string
  name: string
}

Taka operacja przypisania zostanie wtedy odrzucona przez kompilator:

user.id = '123'

Tablice można chronić w ten sam sposób. Parametr typu readonly User[] umożliwia funkcji iterowanie i odczytywanie wartości, ale nie dodawanie nowych elementów, modyfikowanie istniejących ani sortowanie ich na miejscu:

function processUsers(users: readonly User[]) {
  // ...
}

Taka specyfikacja informuje każdego wywołującego, że funkcja nie będzie modyfikować ich kolekcji. readonly jest szczególnie przydatne dla obiektów konfiguracyjnych, danych współdzielonych, stałych, parametrów funkcji oraz stanu niezmiennej. Główną zaletą jest nie tyle blokowanie określonej modyfikacji, ile dokumentowanie intencji dla wszystkich, którzy czytają ten typ. Należy pamiętać, że readonly ma zastosowanie tylko na poziomie powierzchniowym i w czasie kompilacji: zagnieżdżone obiekty pozostają modyfikowalne, chyba że one również zostaną oznaczone tymi właściwościami, a nic nie jest zamykane w czasie wykonywania.

6. Nie ukrywaj prawdziwych struktur za Record<string, unknown>

Takie specyfikacje są powszechne:

function process(data: Record<string, unknown>) {
  // ...
}

Czasami to jest właściwy typ. Jeśli funkcja rzeczywiście przyjmuje dowolne dane w formie klucz-wartość, takie jak ogólny logger lub narzędzie do serializacji, szeroki typ jest w tym przypadku odpowiedni. Problem pojawia się, gdy używamy go mimo że już wiemy, jaki jest obiekt. Weźmy tę samą specyfikację:

function process(data: Record<string, unknown>) {
  // ...
}

i sformatujmy dane, których faktycznie oczekujemy:

type User = {
  id: string
  name: string
}

function process(user: User) {
  // ...
}

Zmiana wygląda tylko powierzchownie, ale przynosi duże korzyści: automatyczne uzupełnianie tekstu, dokumentacja wewnątrz kodu, bezpieczne refaktoryzowanie, gwarancje na etapie kompilacji oraz jasne określenie intencji. Szerokie typy nadają się do sytuacji naprawdę dynamicznych, takich jak parsowanie nieznanych plików JSON, i powinny być jak najszybciej zamieniane na konkretne typy po przekroczeniu tej granicy, zamiast być używane jako domyślne wszędzie.

7. Projektuj interfejsy funkcji, które same się wyjaśniają

Argumenty pozycyjne szybko stają się niejasne, szczególnie wartości logiczne. Czytając taki wywołanie, nie można stwierdzić, co kontrolują true i false, bez otwarcia definicji:

createUser(
  'Akshat',
  'akshat@example.com',
  true,
  false,
)

Obiekt opcji umieszcza znaczenie bezpośrednio w miejscu wywołania:

createUser({
  name: 'Akshat',
  email: 'akshat@example.com',
  sendWelcomeEmail: true,
  isAdmin: false,
})

Następnie funkcja deklaruje nazwany typ dla swoich opcji:

type CreateUserOptions = {
  name: string
  email: string
  sendWelcomeEmail: boolean
  isAdmin: boolean
}

function createUser(options: CreateUserOptions) {
  // ...
}

Korzyści rosną wraz z liczbą parametrów. Dwa argumenty są zazwyczaj w porządku pod względem pozycji; siedem prawie zawsze powoduje błędy, szczególnie gdy kilka z nich ma ten sam typ i można je zamienić bez żadnych konsekwencji. Obiekt opcji ułatwia również późniejsze dodawanie poli opcjonalnych bez uszkadzania istniejących wywołań.

8. Trzymaj typy obok domeny, którą opisują

Wiele projektów zaczyna się od jednego wspólnego pliku types.ts. Na początku jest to wygodne, potem każdy programista coś do niego dodaje, a po roku zawiera on setki niespowiązanych definicji. Znalezienie odpowiedniego typu staje się zadaniem na skalę całego projektu, a plik przekształca się w źródło konfliktów przy łączeniu zmian.

Lepszym rozwiązaniem jest umieszczanie typów razem z kodem domeny, do której należą:

users/
  user.types.ts
  user.service.ts
  user.repository.ts

payments/
  payment.types.ts
  payment.service.ts
  payment.repository.ts

facilities/
  facility.types.ts
  facility.service.ts
  facility.repository.ts

Dokładna struktura folderów ma mniejsze znaczenie niż zasada, na której się opiera: typ powinien znajdować się w domenie, którą opisuje. Jeśli wie pan, gdzie znajduje się logika biznesowa związana z płatnościami, powinien pan móc odgadnąć, gdzie znajdują się typy płatności. Prawdziwie uniwersalne typy, takie jak wspólne struktury API, mogą nadal znajdować się w małym module wspólnym.

9. Utrzymuj system typów w prostszej formie niż logikę biznesową

TypeScript oferuje typy mapowane, typy warunkowe, typy literówki szablonowej, typy rekurencyjne, infer oraz warunki dystrybutywne. Dzięki tym narzędziom można tworzyć praktycznie wszystko na poziomie typów, i właśnie dlatego ważna jest umiarkowanie. Rozważmy taką pomocniczą funkcję, która filtruje obiekt do kluczy kończących się na Id:

type Magic<T> =
  T extends infer U
    ? U extends Record<string, unknown>
      ? {
          [K in keyof U as K extends `${string}Id`
            ? K
            : never]: U[K]
        }
      : never
    : never

Programowanie na poziomie typów ma uzasadnione zastosowania, szczególnie w bibliotekach. Istnieje jednak moment, gdy typ dodaje więcej złożoności, niż ją usuwa. Jeśli kolega z zespołu musi dekodować skomplikowany typ, aby móc zrozumieć regułę biznesową, którą ten typ obsługuje, zastanów się, czy nie wystarczy prostsza wersja. Czasami odpowiedź brzmi „nie”, a złożoność jest uzasadniona; często jednak nie. Bystrość to nie jakość – nudne, łatwe do odczytania typy zwykle przewyższają imponujące, a gdy naprawdę potrzebny jest zaawansowany typ, krótki komentarz i kilka testów typu znacznie ułatwiają jego utrzymanie.

10. Konfiguruj tsconfig celowo

Jednym z najprostszych sposobów osłabienia TypeScript jest konfiguracja, która ignoruje właśnie te problemy, które oczekujesz, że zostaną wykryte. Przynajmniej musisz wiedzieć, co robią te opcje:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

strict włącza serię sprawdzeń, w tym strictNullChecks i noImplicitAny. Pozostałe dwa to oddzielne opcje, które strict nie aktywuje: noUncheckedIndexedAccess dodaje wartość undefined do odczytów z indeksu tablic i obiektów, natomiast exactOptionalPropertyTypes odróżnia właściwość, która jest brakująca, od tej wyraźnie ustawionej na undefined. Oba te parametry mogą ujawnić wiele błędów w istniejącym projekcie.

Odpowiednia kombinacja zależy od struktury kodu. Projekt typu legacy może nie pozwalać na jednoczesne włączenie wszystkich opcji, więc stopniowe aktywowanie flag jest całkowicie uzasadnione. Ważne jest, aby zespół wiedział, co kompilator sprawdza, a czego nie, zaczynając od tej pierwszej opcji:

"strict": true

Tryb ścisły nie ma na celu utrudniania pracy z TypeScriptem. Sprawia, że kompilator jest szczery co do niepewności, co stanowi główny powód używania TypeScriptu: wykrywanie problemów przed użytkownikami.

Dlaczego te nawyki są ważne razem

Żadna z tych praktyk nie jest cenna jako trik. Ich wartość polega na tym, że ułatwiają zrozumienie kodu. Wyobraź sobie nowego kolegę z zespołu, który natrafia na taki typ:

type Payment =
  | {
      status: 'SUCCESS'
      transactionId: string
    }
  | {
      status: 'FAILED'
      error: string
    }

Bez czytania żadnej implementacji dowiaduje się on o regule biznesowej: udana transakcja ma identyfikator transakcji, a nieudana – błąd. Typ informuje o tym, jak ta część systemu funkcjonuje, a nie tylko o tym, że dana właściwość jest łańcuchem znaków. To jest standard, do którego należy dążyć.

Listwa kontrolna przy przeglądaniu kodu

Zanim zapiszesz kod napisany w TypeScriptie, przeanalizuj następujące pytania:

  • Czy to any może być unknown lub konkretnym typem?
  • Czy ta adnotacja powtarza coś, co kompilator już wywnioskował?
  • Czy typy reprezentują tylko ważne stany domeny?
  • Czy ta właściwość jest opcjonalna, ponieważ rzeczywiście taka jest, czy ze względu na wygodę?
  • Czy unia opisałaby ten stan dokładniej?
  • Czy to as jest tu użyte, ponieważ wartość jest udowodnionie bezpieczna, czy tylko po to, by błąd zniknął?
  • Czy ten generyk wyraża rzeczywisty związek między typami?
  • Czy nowa abstrakcja jest łatwiejsza do zrozumienia niż kod, który zastępuje?
  • Czy czytelnik może dowiedzieć się, co oznacza każdy argument w miejscu wywołania?
  • Czy readonly ujednoliciłoby kwestię własności lub niezmienności?
  • Czy kompilator zauważy zmianę w tej domenie?
  • Czy kolega z zespołu może zrozumieć ten typ bez jego dekodowania?

Ostatnie pytanie zazwyczaj ma największe znaczenie.

Podsumowanie: typy jako narzędzie projektowe

Z upływem doświadczenia składnia staje się najmniej interesującą częścią TypeScript. Liczy się to, co decydujesz się wyrazić. Możesz opisać obiekt, który przypadkowo zawiera kilka ciągów znaków, albo operację, która zawsze znajduje się w jednym z czterech stanów, przy czym każdy z nich gwarantuje określony zestaw właściwości. Drugi przypadek jest o wiele bardziej przydatny.

Dobry TypeScript nie ocenia się więc po tym, ile zaawansowanych funkcji potrafi wymienić programista, lecz po tym, jak dobrze system typów pomaga zespołowi w rozumieniu, modyfikowaniu i utrzymywaniu oprogramowania. Gdy kompilator egzekwuje zasady, od których Twoja aplikacja już zależy, typy przestają być siecią bezpieczeństwa i stają się częścią architektury.

  • Używaj generyków do reprezentowania relacji, a zwykłych sygnatur, gdy nie ma potrzeby odzwierciedlenia żadnej relacji.
  • Należy preferować dowody (wąskie filtrowanie, zabezpieczenia, wyczerpujące sprawdzenia) nad stwierdzeniami.
  • Niech typy dokumentują intencję za pomocą atrybutu readonly, precyzyjnych kształtów oraz obiektów opcji.
  • Należy utrzymywać typy blisko ich domeny i prostsze niż logika, której służą.
  • Należy dokładnie wiedzieć, jakie sprawdzenia przeprowadza plik tsconfig, i celowo je usztywniać.
  • Literatura pokrewna