Strona główna / Artykuły / TypeScript nie jest wolny — nadmiernie skomplikowane typy są

TypeScript nie jest wolny — nadmiernie skomplikowane typy są

Uogólniona zupa, przedwczesne wykorzystanie mechanizmu DRY dla różnych typów, stan flagi opcjonalnej oraz pomysłowość na poziomie typów obniżają szybkość pracy. Lepiej używać prostych interfejsów, zjednoczeń dyskryminowanych oraz umiarkowanych kosztów generowania kodu przez tsc.

2143 słów

Jak ogólne rozwiązania, gimnastyka typów warunkowych oraz przedwczesna abstrakcja hamują szybkość dostarczania rozwiązań.

Podczas retrospektywy sprintowej młodszy inżynier przyznaje, że dodanie jednego opcjonalnego pola do istniejącego obciążenia API zajęło cztery godziny. IDE ujawnia powód: interfejs zbudowany z nawarstwionych warunków.

type ExtractNestedPayload<
  T,
  K extends keyof T,
  U extends boolean = false
> =
  T[K] extends (...args: any[]) => infer R
    ? R extends Promise<infer P>
      ? U extends true ? NonNullable<P> : P
      : R
    : T[K] extends Array<infer Item>
      ? Item
      : never;

Cztery warstwy nawarstwionych warunków, trzy parametry ogólne oraz łańcuch ternarny, który wymaga tablicy do notatek. Błędy kompilatora pojawiają się w postaci długich, czerwonych podpowiedzi:

Type 'ExtractNestedPayload<ApiResponses["users"], "data", true>'
is not assignable to type 'UserDTO'.
Types of property 'meta' are incompatible.Type 'unknown' is not assignable to type
'{ pagination: PaginationMeta }'.

Ciągle pojawia się ta sama skarga: sam TypeScript spowalnia pracę zespołu. Zazwyczaj tak nie jest – problemem są przetworzone w nadmiarze abstrakcje. TypeScript miał służyć jako praktyczna ochrona: mniej awarii w trakcie działania i lepsze funkcje autodopasowywania. Jednak w trakcie jego stosowania wiele baz kodu przekształciło go w rodzaj zagadki – ludzie spędzają godziny na tworzeniu „doskonałych” generyków z punktu widzenia matematyki, by uniknąć zaledwie kilku prostych deklaracji. Im bardziej skomplikowany staje się graf typów, tym mniej pomaga on osobom odpowiedzialnym za wdrażanie funkcjonalności. Typ powinien przekazywać intencję. Jeśli jego zrozumienie wymaga użycia typów warunkowych, specyficznych mechanizmów inferencji, typów mapowanych, związków dystrybutywnych, generyków rekurencyjnych oraz serii narzędzi pomocniczych, intencja znika i trzeba debugować zupełnie inny system. Poniżej przedstawiono wzorce, które w warunkach produkcyjnych przynoszą negatywne skutki, oraz prostsze alternatywy, które przywracają efektywność.

1. Antywzorzec „generycznego gulaszu”

Generyki stanowią podstawę dla Promise<T>, Array<T> oraz Map<K, V>. Problemy pojawiają się wtedy, gdy elastyczność staje się oznaką wyższej kwalifikacji. Więcej generyków nie oznacza automatycznie lepszej jakości. Weźmy na przykład komponent tabeli:

// The Generic Soup Nightmare
interface TableProps<
  TData,
  TKey extends keyof TData,
  TColumn extends ColumnDef<TData, any>,
  TFilter extends Record<string, any> = Record<string, any>
> {
  data: TData[];
  keyExtractor: (item: TData) => TData[TKey];
  columns: TColumn[];
  initialFilter?: TFilter;
  onRowClick?: (row: TData) => void;
}

Wydaje się on nieograniczenie wielokrotnie użyteczny: struktura danych, klucz wiersza, kolumny, filtry. Każda abstrakcja wiąże się z określonym kosztem poznawczym. Miejsca wywołań zmuszają kompilator do wnioskowania o kilku wzajemnie powiązanych parametrach. Niewielka niezgodność w propach może nie wskazywać na konkretną przyczynę problemu; wnioskowanie może dotyczyć całej struktury. Nowy kolega z zespołu musi nauczyć się, dlaczego istnieje TKey, dlaczego rozszerza on keyof TData, jak współdziałają generyki kolumn i filtrów oraz co faktycznie wywnioskował kompilator. To duży ciężar dla takiego komponentu jak tabela.

To opodatkowanie objawia się wolniejszą oceną kodu, dłuższym procesem wdrożenia oraz kulturą, w której tylko jedna lub dwie osoby ośmielają się zmieniać wspólne elementy interfejsu użytkownika. Spadek wydajności ma charakter zarówno techniczny, jak i społeczny: koledzy z zespołu przestają proponować małe ulepszenia ze strachu przed konsekwencjami. Gdy abstrakcja tabeli wymaga dokumentu projektowego, aby wyjaśnić jej elementy typowe, oznacza to, że abstrakcja ta przerosła problem, który miała rozwiązać.

Symptomy w środowisku produkcyjnym są nudne i kosztowne. Zmiana nazwy jednej zmiennej powoduje uruchomienie diagnostyki, która wspomina o niepowiązanych parametrach typu. Funkcja autodopasowywania się zatrzymuje pracę, podczas gdy usługa językowa ponownie analizuje strukturę typową. Czas potrzebny na sprawdzanie typów w procesie CI stale rośnie, mimo że nikt nie wprowadza bardziej przejrzystego modelu domeny. Żadne z tych kosztów nie pojawia się w porównaniach typu „TypeScript vs JavaScript”; widoczne są one w kalendarzowym czasie.

Konkretowa alternatywa

Należy preferować jeden parametr danych oraz proste interfejsy pomocnicze:

// Simple, readable, and instant compiler diagnostics
interface TableColumn<T> {
  header: string;
  accessor: (item: T) => React.ReactNode;
  width?: string;
}
interface DataTableProps<T> {
  data: T[];
  columns: TableColumn<T>[];
  rowKey: (item: T) => string;
}

Komponent pozostaje wielokrotnie używalny, nie stając się źródłem informacji o typach. Wielokrotna użyteczność nie oznacza nieskończonej ogólności.

Wielokrotna użyteczność nie oznacza nieskończonej ogólności.

Zespoły czasami obawiają się, że uproszczenie struktur ogólnych doprowadzi do konieczności kopiowania i wklejania kodu. W praktyce dwie lub trzy spersonalizowane wersje tabel z jasno określonymi właściwościami są lepsze niż jeden uniwersalny komponent, którego nikt nie może zainstalować bez prób i błędów. Należy dzielić się narzędziami do renderowania oraz plikami CSS; publiczne właściwości powinny być proste. Wtedy kompilator wskazuje dokładnie na niepasujące pole, zamiast łączyć cztery zmienne inferencji w nieskończoną pułapkę typu unknown.

2. Przedwczesne stosowanie zasady DRY w definicjach typów

Zasada „Nie powtarzaj się” jest przydatna w kodzie uruchamianym w czasie wykonywania, ale stanowi zagrożenie, gdy jest ślepo stosowana do typów. Widząc podobne pola, zespoły tworzą jeden typ na podstawie drugiego:

// Over-abstracted type derivation
type RegisterFormValues =
  Omit<
    UserProfile,
    'id' | 'createdAt' | 'updatedAt' | 'role'
  > & {
    passwordConfirmation: string;
    termsAccepted: boolean;
  };

Później zmienia się entytet:

interface UserProfile {
  // ...
  phoneNumber: string; // now required!
}

Typ formularza pochodnego w tajemnicy odziedzicza polе obowiązkowe phoneNumber, którego proces rejestracji wcale nie wymagał. Następują dalsze korekty:

type RegisterFormValues =
  Omit<
    UserProfile,
    'id' |
    'createdAt' |
    'updatedAt' |
    'role' |
    'phoneNumber'
  > & {
    phoneNumber?: string;
    passwordConfirmation: string;
    termsAccepted: boolean;
  };

Każde pominięcie zwiększa brak przejrzystości. Formularz i entytet w bazie danych zmieniają się z różnych powodów; ich łączenie powoduje nieoczekiwane błędy.

Duplikacja jest tańsza niż niewłaściwa abstrakcja

Sformułuj umowy oddzielnie:

// Database Entity Contract
export interface UserProfile {
  id: string;
  email: string;
  fullName: string;
  phoneNumber: string;
  createdAt: string;
}
// Registration Form Contract
export interface RegisterFormValues {
  email: string;
  fullName: string;
  phoneNumber?: string;
  password: string;
  passwordConfirmation: string;
  termsAccepted: boolean;
}

Kilka duplikowanych pól kosztuje mniej niż krucha struktura pochodzenia typów. Jeśli dwa typy zmieniają się z różnych powodów, prawdopodobnie nie powinny być ze sobą powiązane.

Jeśli dwa typy zmieniają się z różnych powodów, prawdopodobnie nie powinny być ze sobą powiązane.

Korzystny test zapachowy: czy menedżer produktu opisałby je jako ten sam koncept? Wiersz profilu użytkownika w bazie danych oraz formularz rejestracyjny na stronie marketingowej rzadko dzielą ten sam cykl życia, reguły walidacji czy właściciela. Gdy występują rozbieżności, typy pochodne potęgują te problemy, powodując błędy kompilacji dalekie od tego, co je spowodowało. Wyraźne interfejsy sprawiają, że te rozbieżności są widoczne i ograniczone do konkretnego obszaru. Pomocniki mapowania – małe funkcje przekształcające dane z entytetów do domyślnych formularzy – zapewniają uczciwą konwersję w czasie wykonywania, bez trwałego łączenia tożsamości typów.

3. Unie dyskryminowane są lepsze od chaotycznych właściwości opcjonalnych

Stan interfejsu asynchronicznego często wygląda w ten sposób:

// The "Optional Flag" Anti-Pattern
interface RequestState<T> {
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  data?: T;
  error?: Error;
}

Klienci wymyślają wtedy nielegalne kombinacje — isSuccess z brakującym data, lub isLoading z nadal ustawionym błędem:

if (state.isSuccess && state.data) {
  return <div>{state.data.name}</div>;
}

Opcjonalne flagi nie kodują maszyny stanów; one kodują nadzieję.

Moc zjednoczeń dyskryminowanych

Należy jasno określić stan:

export type AsyncState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: Error };

Renderyzacja staje się wtedy wyczerpująca i bezpieczna:

function RenderProfile({
  state
}: {
  state: AsyncState<UserProfile>;
}) {
  switch (state.status) {
    case 'idle':
      return <div>Ready to load profile.</div>;
    case 'loading':
      return <LoadingSpinner />;    case 'error':
      return <ErrorMessage error={state.error} />;    case 'success':
      // TypeScript guarantees state.data exists here!
      return <h1>Welcome, {state.data.fullName}</h1>;
  }
}

Niemal niemożliwe stany znikają z definicji typu, dzięki czemu wiele mechanizmów obronnych znika z interfejsu użytkownika.

Niemal niemożliwe stany znikają z definicji typu, dzięki czemu wiele mechanizmów obronnych znika z interfejsu użytkownika.

Modele z flagą opcjonalną również wprowadzają zamieszanie pomiędzy analizą danych a rejestracją zdarzeń. Czy żądanie było udane, jeśli isSuccess ma wartość true, ale data nie jest zdefiniowany? Unie dyskryminujące wymagają odpowiedzi na to pytanie podczas konstruowania stanu, a nie gdy młodszy inżynier zgaduje w kodzie JSX. Reduktorzy i otulacze asynchroniczne stają się również jaśniejsze: każda transformacja zwraca kompletną wersję, zamiast zmieniać wartości logiczne, które mogą się różnić.

4. Błąd myślowy any vs unknown

Użycie any w celu uciszenia kompilatora eliminuje korzyści płynące z TypeScript na granicy interfejsu. Lepiej używać unknown i zwężać typy za pomocą mechanizmów ochronnych podczas czytania danych z API lub localStorage.

Zastąp any przez unknown + mechanizmy ochrony typów

// Safe parsing of unknown API or localStorage data
function parseStoredPreferences(
  raw: unknown
): UserPreferences {
  if (
    typeof raw === 'object' &&
    raw !== null &&
    'theme' in raw &&
    (raw.theme === 'light' || raw.theme === 'dark')
  ) {
    return {
      theme: raw.theme,
      fontSize:
        typeof (raw as any).fontSize === 'number'
          ? (raw as any).fontSize
          : 14,
    };
  }
  // Safe fallback default
  return {
    theme: 'dark',
    fontSize: 14
  };
}

Dane zewnętrzne pozostają niewiarygodne, dopóki nie zostaną zweryfikowane; wtedy kod wewnętrzny przyjmuje konkretną formę.

Dane zewnętrzne pozostają niewiarygodne, dopóki nie zostaną zweryfikowane; wtedy kod wewnętrzny przyjmuje konkretną formę.

any jest szczególnie szkodliwe na granicach modułów, ponieważ zakłóca procesy inferencji poniżej: jeden element typu any z JSON.parse może unieważnić wszystkie sprawdzenia w całej funkcji. unknown zapobiega temu zakłóceniu na wstępie. Należy go łączyć z bibliotekami do walidacji schematów, gdy dane są duże, lub z ręcznie napisanymi mechanizmami ochronnymi, gdy struktury są małe i stabilne. W obu przypadkach rdzeń aplikacji powinien widzieć wyłącznie zweryfikowane typy.

5. Mierzenie czasu sprawdzania typów w CI

Gdy edytor wydaje się wolny, najpierw zmierz jego wydajność, zanim będziesz winić sam język:

npx tsc --noEmit --extendedDiagnostics

Nadzoruj liczbę instancji, pliki oraz czas ich tworzenia. Często dominują kosztowne warunki rekurencyjne. Jeśli system typów jest drogi w kompilacji, powinien usprawiedliwić ten koszt.

Jeśli system typów jest drogi w kompilacji, powinien usprawiedliwić ten koszt.

Rozszerzone diagnostyki często ujawniają niewielką liczbę plików, które odpowiadają za większość instancji – często są to narzędzia warunkowe o charakterze rekurencyjnym, importowane dla wygody. Usunięcie lub uproszczenie tych elementów może zaoszczędzić minuty na każdym uruchomieniu CI. Śledź tę liczbę w czasie w taki sam sposób, jak śledzisz rozmiar plików. Architektura typów, która nie potrafi wytłumaczyć swojego kosztu kompilacji, ostatecznie będzie obwiniana za wolność TypeScript, a zespół będzie inwestował zbyt mało w język, który nadal chroni go w czasie wykonywania programu.

6. Problem sprytu na poziomie typów

Bystrość to kolejna pułapka: typy, które wywodzą całą strukturę API z innych typów, a następnie dodają coraz więcej warunków, typów mapowanych i rekurencji, aż nikt ich już nie chce używać. Możliwości techniczne nie są powodem do stosowania najbardziej skomplikowanych funkcji.

Porównaj bezpośredni dostęp za pomocą indeksu:

type UserName = UserProfile['fullName'];

z rekurencyjną funkcją pomocniczą, która przeszukuje każdą właściwość o wartości typu ciąg w dowolnym obiekcie. Jeśli produkt potrzebuje tylko UserProfile['fullName'], taka złożoność wprowadza ryzyko. Dobry projektowanie polega na wyborze najprostszego i najbardziej przejrzystego narzędzia. Typ powinien być zrozumiały po sześciu miesiącach; stwierdzenie „nie dotykać tego” oznacza, że abstrakcja już zawiodła.

7. Pragmatyczny manifest TypeScript

1. Najpierw pisz typy dla ludzi, a dopiero potem dla kompilatora

Jeśli koledzy z zespołu nie potrafią zrozumieć definicji w mniej niż minutę, uproszczaj ją. Elegancja, której rozumie tylko autor, jest długiem technologicznym.

2. Wolimy duplikację niż przedwczesne powiązania

Nie modyfikuj interfejsu komponentu tylko po to, by ponownie wykorzystać trzy pola z niepowiązanej entytety. Oddziel odpowiedzialności, oddziel typy.

3. Używaj zjednoczeń dyskryminujących do reprezentacji stanu

Koduj prawdziwe maszyny stanowe za pomocą dyskryminatora stanu, aby kompilator mógł usunąć niemożliwe ścieżki.

4. Nigdy nie pozwalaj, by generyki miały więcej niż dwa parametry

Trzy lub więcej generyków zazwyczaj oznacza, że abstrakcja jest zbyt szeroka. Podziel ją. To heurystyka, a nie reguła — gdy trudno jest wyjaśnić relacje, przeanalizuj ponownie projekt.

5. Traktuj dane zewnętrzne jako nieznane

Odpowiedzi API, dane przechowywane, treści od podmiotów trzecich oraz dane wprowadzone przez użytkownika powinny być weryfikowane na granicy, zanim staną się zaufanymi obiektami domeny.

6. Mierz przed tym, jak obwiniać TypeScript

Powolny CI lub opóźnione usługi językowe często wynikają z architektury typów, a nie z marki języka. Przeanalizuj profil i uproszcz następnie typy często używane.

TypeScript powinien sprawiać, że kod jest nudny

TypeScript osiąga najlepsze efekty, gdy pozostaje w tle: autodopisywanie kodu, bezpieczne refaktoryzacje, mniej niespodzianek podczas działania programu. Nie powinien sprawiać wrażenia zagadki przy każdej zmianie komponentu. Najmniej imponujące interfejsy – proste obiekty biznesowe – są często najcenniejsze. Zachowuj typy proste, praktyczne i powiązane z rzeczywistymi koncepcjami produktu, aby zespół mógł realizować projekty, a nie zajmował się dekodowaniem algebry typów.

Zachowuj typy proste, praktyczne i powiązane z rzeczywistymi koncepcjami produktu, aby zespół mógł realizować projekty, a nie zajmował się dekodowaniem algebry typów.

Retrospektywy stają się lepsze, gdy rozmowa przechodzi od aspektów związanych z językiem programowania do wyborów projektowych: ile używać typów ogólnych, jak dużo pochodnych tworzyć, na ile jest uczciwa maszyna stanów, w jaki sposób dane z zewnątrz trafiają do aplikacji. TypeScript nagradza tę uczciwość szybszą informacją zwrotną na temat zmian, które są istotne. Karze natomiast spryt za pomocą niejasnych błędów. Celowo wybieraj prostsze rozwiązania, udokumentuj te nieliczne zaawansowane typy, które faktycznie są kluczowe, i unikaj wzorów typu „zadania dla myślicieli” w bibliotekach współdzielonych, z którymi muszą pracować nowi inżynierowie od pierwszego dnia.

Gdy wydaje się, że zmiana jest blokowana przez typy, zastanów się, czy model odzwierciedla produkt. Często rozwiązaniem nie jest bardziej złożona logika warunkowa – lecz jaśniejszy interfejs, podzielony moduł lub typ unii opisujący stany, o których już rozmawiamy podczas spotkań. W ten sposób TypeScript przestaje stanowić obciążenie i znów staje się narzędziem wspomagającym pracę.

Literatura pokrewna

  • TypeScript Power Tools: extends, infer, keyof Maps oraz as Remaps — Twórz typy na poziomie bibliotek poprzez łączenie warunków, mechanizmów inferencji, pętli mapowania oraz przemapowywania kluczy – to ten sam zestaw narzędzi, który znajduje się u podstaw Zod i tRPC.