Strona główna / Artykuły / TypeScript 6.0 wyjaśniony: zmiany w kompilatorze i opanowanie generyków

TypeScript 6.0 wyjaśniony: zmiany w kompilatorze i opanowanie generyków

Wyjaśnia zmiany w kompilatorze przejściowym TypeScript 6.0 i pokazuje, jak stosować generyki w celu tworzenia bezpieczniejszego i bardziej wielokrotnie używalnego kodu na poziomie typów.

2691 słów

TypeScript rozwija się jednocześnie w dwóch kierunkach: sam kompilator staje się bardziej wydajny i odporny, natomiast najpotężniejszy narzędzie systemu typów — generyki — pozostaje kluczem do pisania kodu, który zachowuje bezpieczeństwo w miarę jego rozszerzania. Zrozumienie zmian w TypeScript 6.0 na poziomie implementacji oraz opanowanie generyków daje jasny obraz tego, jak pisać kod w TypeScriptie, który jest zarówno przygotowany na przyszłość, jak i naprawdę wielokrotnie wykorzystywalny. Zacznij od zmian w kompilatorze, ponieważ one określają standard, na którym będzie działać każda baza kodu oparta na generykach.

Wydanie przejściowe w drodze do kompilatora natywnego

TypeScript 6.0 nie jest przede wszystkim wersją z nowymi funkcjami skierowaną do użytkowników — to most łączący stare i nowe rozwiązania. Zespół TypeScript jasno określił go jako wersję przejściową: ostatnią wersję opartą na oryginalnej bazie kodu z JavaScriptem, zanim TypeScript 7.0 zostanie wydany jako pełna przepisana wersja oparta na języku Go. Jeśli aktualizacja do 6.0 wydaje się niezwykle spokojna, to jest tak zamiarem twórców. Większość istotnych zmian została przeznaczona na wersję 7.0.

Co faktycznie się zmienia

W wersji 6.0 pojawiło się kilka konkretnych zmian:

  • Tryb ścisły jest teraz domyślnym ustawieniem dla nowych projektów. Nie musisz już ręcznie włączać "strict": true — zamiast tego bazy kodu oparte na luźnym typowaniu muszą wyraźnie ustawić "strict": false. Intencja zespołu jest jasna: chcą, abyś naprawiał podstawowe problemy z typami, zamiast je tłumić.
  • Tablica types jest pusta domyślnie. TypeScript wcześniej automatycznie ładował każdy pakiet znajdujący się w node_modules/@types. Teraz nic nie jest ładowane, chyba że zostanie to wyraźnie wymienione, co może znacząco skrócić czas budowania w większych projektach.
  • Domyślnie target i module mają wartości es2025 i esnext odpowiednio. Generowanie plików w starszym formacie ES5 jest praktycznie przestarzałe.
  • Dostępne są typy z wbudowanej API Temporal, które umożliwiają statyczne i bezpieczne zarządzanie datami i godzinami bez konieczności korzystania z bibliotek takich jak date-fns czy Luxon. To główna funkcja, o którą prosili deweloperzy.
  • // Before: juggling Date math and timezone offsets manually
    const deadline = new Date(Date.now() + 86400000);
    
    // TypeScript 6.0: Temporal makes intent explicit
    const now = Temporal.Now.zonedDateTimeISO("Asia/Kolkata");
    const deadline = now.add({ hours: 24 });
    
    • Proces wnioskowania ulepsza się w przypadku funkcji, które nie używają this, a TypeScript teraz obsługuje importy poddrożów z prefiksem # oraz pozwala łączyć moduleResolution: bundler z module: commonjs — kombinację, która wcześniej nie była możliwa.
    • Biblioteka standardowa otrzymuje metody Map.getOrInsert, Map.getOrInsertComputed oraz wbudowaną funkcję RegExp.escape(), co eliminuje potrzebę stosowania własnych narzędzi do escape’owania.
    • --baseUrl jest przestarzały. Przenieś aliasy dróg do pola paths w pliku tsconfig przed całkowitą usunięciem baseUrl w wersji 7.0.

    Dlaczego istotna jest ta przejściowa natura

    Zdaniem zespołu TypeScript, powodem tej wersji jest przygotowanie programistów na wdrożenie wersji 7.0, która wprowadza kompilator oparty na języku Go, obiecujący szybsze budowanie projektów o 40–60% w porównaniu z obecnymi rozwiązaniami. Praktycznym wnioskiem jest to, że ta wersja stanowi wasze zadanie domowe: usuńcie już teraz ostrzeżenia o przestarzałych funkcjach, dzięki czemu wersja 7.0 będzie wydawać się darmowym ulepszeniem wydajności, a nie zakłócającą migracją, szczególnie w nowoczesnych środowiskach Node.js.

    Co zrobić przed wdrożeniem 7.0

    • Zainstalujcie tsc --init i sprawdźcie wcześnie nowe błędy związane z trybem ścisłym, które on wykrywa.
    • Przenieście konfigurację z pola baseUrl do pola paths już teraz, zanim zostanie ono usunięte.
    • Jawnie zadeklarujcie swój array types zamiast polegać na automatycznym ładowaniu.
    • Rozpocznijcie wprowadzanie Temporal do kodu o niskim ryzyku, aby się z nim zapoznać.

    TypeScript ma długą historię przekształcania dzisiejszych najlepszych praktyk w standardy jutra. Wersja 6.0 to spokojny etap przygotowawczy przed nadejściem przyszłości o prędkości natywnej — wykorzystaj ją do uporządkowania swojej konfiguracji, aby gdy pojawi się wersja 7.0, przejście było prawie niezauważalne.

    Od dyscypliny konfiguracyjnej do myślenia na poziomie typów

    Wersjonowanie i flagi kompilatora to zaledwie połowa historii pisania solidnego kodu w TypeScript. Drugą połową jest umiejętność strukturyzowania własnych typów w taki sposób, aby kompilator mógł naprawdę ci pomóc — i to prowadzi nas do generyków, które są bez wątpienia kluczowym elementem odróżniającym programistów walczących z systemem typów od tych, którzy potrafią go swobodnie używać.

    Prawie każdy programista używający TypeScript dochodzi do tego samego rozdroża. Na początku ten język sprawia wrażenie skrupulatnego bibliotekarza, który obserwuje każdy twój ruch: piszesz interfejs dla User, kolejny dla Product, jeszcze jeden dla BlogPost, i wszystko pozostaje uporządkowane oraz bezpieczne.

    Następnie baza kodu się rozrasta.

    Potrzebujesz funkcji do pobierania danych o User z API, potem takiej dla Product, a jeszcze innej dla BlogPost. Albo próbujesz ominąć ten problem, pisząc jeden wspólny wrapper, wtedy toniesz w błędach kompilacji i ostatecznie używasz any wszędzie, tylko po to, by zniknęły czerwone komunikaty błędów – nieświadomie usuwając sieć bezpieczeństwa, którą TypeScript miał ci zapewnić.

    To właśnie jest ta bariera, która zatrzymuje wielu programistów. Przezwyciężenie jej oznacza rzeczywiste zrozumienie generyków.

    Generiki to nie żaden trik składniowy, który trzeba zapamiętać na rozmowę kwalifikacyjną. Są one strukturalnym filarem kodu, który można wykorzystywać wielokrotnie, łatwo go utrzymywać i skalować. Gdy już zrozumiesz ten koncept, przestaniesz ręcznie tworzyć powtarzalne szablony i zaczniesz projektować systemy w sposób, jaki stosowałby doświadczony inżynier.

    Budowanie właściwego modelu mentalnego

    Zapomnij na chwilę o formalnym ujęciu z informatyki i pomyśl o tym, jak zachowuje się zwykła funkcja w JavaScript. Nigdy nie wpisujesz wartości stałej bezpośrednio w ciele funkcji:

    // Hardcoded: Only works for one specific person
    function greetRahul() {
      return "Hello, Rahul!";
    }
    
    // Dynamic: Uses a parameter as a placeholder for data
    function greet(name: string) {
      return `Hello, ${name}!`;
    }
    

    Parametr name to nic innego jak zamiennik wartości, która zostanie podana później, w momencie wywołania.

    Generyk działa dokładnie w ten sam sposób — z tą różnicą, że zamiast zastępować wartość, zastępuje typ. Funkcje, klasy i interfejsy mogą przyjmować typy jako argumenty w taki sam sposób, w jaki zwykłe funkcje przyjmują wartości jako argumenty.

    Zobaczmy zwykłą tekturową skrzynię do przesyłek. W fabryce nikt jeszcze nie wie, czy będzie zawierać laptopa, parę butów czy ceramiczną filiżankę — to po prostu generyczny kontener, Box<T>. Jeśli włożymy do niej laptopa, staje się Box<Laptop>; jeśli buty, to staje się Box<Shoes>. Sama skrzynia jest obojętna na swój zawartość, ale zawsze wiemy, co się w niej znajduje: otwierając Box<Laptop>, wiemy, że możemy ją włączyć, otwierając Box<Shoes>, wiemy, że możemy je założyć. Nie ma tu miejsca na domysły.

    Rozwiązanie problemu duplikacji za pomocą generyków

    Załóżmy, że mamy narzędzie, które pakuje dane wraz z metadanymi, takimi jak data i czas oraz wygenerowany identyfikator. Bez generyków trzeba byłoby pisać niemal identyczne funkcje pakowania dla każdego modelu w aplikacji:

    // The Brute-Force Approach: Duplicate functions for every entity
    interface User {
      name: string;
      role: string;
    }
    
    interface Product {
      title: string;
      price: number;
    }
    
    function wrapUser(item: User) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    function wrapProduct(item: Product) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    To jest bezpośrednie złamanie zasady DRY — dwadzieścia modeli danych oznacza dwadzieścia niemal identycznych funkcji pakowania.

    Kuszącym sposobem obejścia jest użycie any, aby pozbyć się duplikacji:

    function wrapItem(item: any) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    
    const wrapped = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript has no idea what 'wrapped.data' is!
    // Autocomplete is dead. Typos will crash in production.
    console.log(wrapped.data.nonExistentProperty); // Compiles without error, fails at runtime!
    

    Kompilator przestaje zgłaszać błędy, ale za to milczenie trzeba zapłacić utratą bezpieczeństwa typów, autodopasowywania i możliwości bezpiecznego refaktoryzowania — właśnie tych rzeczy ma zapewnić TypeScript.

    Lepszym rozwiązaniem jest wyrażenie tej samej funkcji pomocniczej za pomocą generyków:

    function wrapItem<T>(item: T) {
      return {
        id: crypto.randomUUID(),
        createdAt: new Date(),
        data: item,
      };
    }
    

    Syntaksa <T> robi jednocześnie trzy rzeczy. Po pierwsze, deklaruje zmienną typu o nazwie T, którą może używać ta funkcja. Po drugie, zapisanie parametru jako (item: T) oznacza, że typ argumentu będzie taki sam jak typ T w momencie wywołania funkcji. Po trzecie, ponieważ typ zwracany również odnosi się do T, dokładny typ wejścia przechodzi bezpośrednio do wyniku, w tym przypadku jako data: T.

    const userResult = wrapItem({ name: "Alex", role: "Admin" });
    
    // TypeScript automatically infers that T is { name: string; role: string }
    console.log(userResult.data.name); // Full autocomplete works!
    console.log(userResult.data.invalidProp); // Error: Property 'invalidProp' does not exist!
    

    Wywołaj tę funkcję obiektem w kształcie User, a TypeScript automatycznie wywnioskuje typ T – nie ma potrzeby żadnych adnotacji – a następnie przenosi ten wywnioskowany kształt do właściwości data zwracanej przez funkcję, dzięki czemu userResult.data.name korzysta z pełnego autodopasowywania i sprawdzania typów, zamiast polegać na ślepej ufności, jaką narzuciłby typ any.

    Dodawanie ograniczeń poprzez warunki

    Zostawienie T całkowicie bez ograniczeń sprawdza się dobrze w przypadku ogólnych pomocników typu identyfikator, ale wiele rzeczywistych funkcji musi zakładać coś na temat kształtu swojego wejścia. Zamiast akceptować dosłownie wszystko, często chce się powiedzieć „każdy typ, pod warunkiem że ma taki wygląd”. Właśnie to dają ogólne warunki, przy użyciu słowa kluczowego extends do określenia, czym może być T.

    Rozważmy funkcję przeznaczoną do wydruku unikalnego ID entity:

    // This causes a compiler error!
    function printId<T>(entity: T) {
      console.log(entity.id);
      // Error: Property 'id' does not exist on type 'T'.
    }
    

    To nie kompiluje się, ponieważ nic nie informuje TypeScript o tym, że T ma pole id. T może równie dobrze być number, boolean, null lub pustym obiektem, z których żaden nie gwarantuje dostępu do .id.

    Rozwiązaniem jest ograniczenie T do kształtu, który zawiera właściwość id:

    interface HasId {
      id: string | number;
    }
    
    function printId<T extends HasId>(entity: T) {
      // Safe! TypeScript guarantees entity has an 'id' property.
      console.log(`Entity ID: ${entity.id}`);
      return entity;
    }
    
    // Works perfectly:
    printId({ id: 101, name: "Database Record" });
    printId({ id: "usr_99", email: "dev@example.com" });
    
    // Fails at compile time before hitting production:
    printId({ name: "Unsaved Item" });
    // Error: Argument of type '{ name: string; }' is not assignable to parameter of type 'HasId'.
    

    Napisanie T extends HasId mówi kompilatorowi, że może przyjąć dowolny typ, pod warunkiem że spełnia minimalny wymóg posiadania właściwości id.

    Szybkie wyszukiwanie z bezpieczeństwem typowym za pomocą keyof

    Klasycznym źródłem błędów w JavaScript jest dostęp do nieistniejącej właściwości, często z powodu błędu pisowni, np. user.fristName zamiast user.firstName. Połączenie generyków z operatorem keyof umożliwia tworzenie narzędzi, w których tego typu błędy stają się strukturalnie niemożliwe do wystąpienia.

    function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
      return obj[key];
    }
    
    const employee = {
      id: 42,
      name: "Sarah Connor",
      department: "Security",
      isActive: true,
    };
    
    // Autocomplete offers: "id" | "name" | "department" | "isActive"
    const empName = getProperty(employee, "name"); // Type inferred as: string
    const empActive = getProperty(employee, "isActive"); // Type inferred as: boolean
    
    // Typos are caught immediately:
    const badProp = getProperty(employee, "deparment");
    // Error: Argument of type '"deparment"' is not assignable to parameter of type '"id" | "name" | "department" | "isActive"'.
    

    Oto dlaczego ten wzorzec jest tak potężny:

    • T oznacza kształt obiektu, z którym pracujemy.
  • keyof T zwraca zbiór wszystkich ważnych kluczy w T, na przykład "id" | "name" | "department" | "isActive".
  • K extends keyof T wymusza, aby key był jedną z tych dosłownych ciągów znaków, niczym innym.
  • T[K] sprawia, że typ zwracany będzie odpowiadał dokładnemu typowi wartości przechowywanej pod tym kluczem.
  • To, co wygląda jak mała funkcja pomocnicza, jest w rzeczywistości umową z czasu kompilacji, która całkowicie eliminuje błędy pisowni w nazwach właściwości.

    Projektowanie wielokrotnie używalnego klienta API

    Poza izolowanymi narzędziami, generyki naprawdę przynoszą korzyści w kodzie na skalę produkcyjną. Prawie każda aplikacja internetowa komunikuje się z jakimś serwerem, a większość API REST opakowuje swoje odpowiedzi w spójną obwódkę JSON:

    {
      "status": "success",
      "statusCode": 200,
      "data": { ... },
      "message": "Operation successful"
    }
    

    Zamiast ręcznie tworzyć oddzielny typ odpowiedzi dla każdego punktu końcowego, można zdefiniować jeden uniwersalny szablon i używać go wszędzie:

    // 1. The Generic Contract
    interface ApiResponse<TData> {
      status: "success" | "error";
      statusCode: number;
      data: TData;
      message?: string;
    }
    
    // 2. The Pagination Envelope
    interface PaginatedList<TItem> {
      items: TItem[];
      totalCount: number;
      page: number;
      pageSize: number;
    }
    

    Gdy taki kontrakt zostanie wprowadzony, sam klient HTTP staje się niezwykle zwięzły i wielokrotnie używalny:

    async function fetchApi<T>(url: string): Promise<ApiResponse<T>> {
      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      return response.json();
    }
    
    // Concrete Domain Models
    interface UserProfile {
      id: string;
      username: string;
      email: string;
    }
    
    interface OrderHistory {
      orderId: string;
      totalAmount: number;
      currency: string;
    }
    
    // Usage Example 1: Fetching a single user
    async function loadUser() {
      const response = await fetchApi<UserProfile>("/api/v1/profile");
    
      // Fully typed:
      console.log(response.data.username);
    }
    
    // Usage Example 2: Fetching a paginated list of orders
    async function loadOrders() {
      const response = await fetchApi<PaginatedList<OrderHistory>>("/api/v1/orders");
    
      // Fully typed nested structures:
      response.data.items.forEach(order => {
        console.log(`Order #${order.orderId}: ${order.totalAmount}`);
      });
    }
    

    Korzyści są znaczne: bez konieczności pisania osobnej funkcji pobierania danych dla każdej trasy, każdy punkt końcowy automatycznie odziedzicza pełne bezpieczeństwo typów od żądania do odpowiedzi, a także precyzyjne uzupełnianie treści i znacznie łatwiejszą konserwację w dłuższej perspektywie.

    Wprowadzanie generyków do wielokrotnie używanych komponentów interfejsu

    To samo zasada naturalnie odnosi się do warstwy komponentów. Jeśli budujesz interfejsy w React, Vue lub zwykłych Web Components, najprawdopodobniej w pewnym momencie napisałeś spadający menu, tabelę lub listę. Bez generyków te komponenty wielokrotnego użycia mają tendencję do awarii, gdy tylko potrzebujesz, aby obsługiwały różne formy danych.

    Weźmy jako przykład generyczny komponent tabeli zbudowany w React:

    interface TableProps<T> {
      data: T[];
      renderRow: (item: T, index: number) => React.ReactNode;
      keyExtractor: (item: T) => string | number;
    }
    
    export function GenericTable<T>({ data, renderRow, keyExtractor }: TableProps<T>) {
      return (
        <table>
          <tbody>
            {data.map((item, index) => (
              <tr key={keyExtractor(item)}>
                {renderRow(item, index)}
              </tr>
            ))}
          </tbody>
        </table>
      );
    }
    

    Jego użycie wygląda w ten sposób:

    interface Customer {
      id: string;
      fullName: string;
      loyaltyPoints: number;
    }
    
    const customers: Customer[] = [
      { id: "c1", fullName: "Elena Rostova", loyaltyPoints: 450 },
      { id: "c2", fullName: "David Miller", loyaltyPoints: 1200 },
    ];
    
    function CustomerList() {
      return (
        <GenericTable
          data={customers}
          keyExtractor={(customer) => customer.id} // customer is inferred as Customer!
          renderRow={(customer) => (
            <>
              <td>{customer.fullName}</td>
              <td>{customer.loyaltyPoints} pts</td>
            </>
          )}
        />
      );
    }
    

    Zauważ, że nie ma konwersji typu za pomocą as Customer, żadnego any ani konieczności domyślania się. Jeśli kolega z zespołu później przemieni nazwę fullName na name w interfejsie Customer, TypeScript natychmiast pokaże wszystkie miejsca w interfejsie użytkownika, które nadal wymagają aktualizacji.

    Zachowanie czytelności generyków: trzy zasady

    Generiki są potężne, ale ta moc skłania do nadmiernego projektowania. Bazy kodu czasami zawierają potwory w rodzaju ProcessData<T, Record<string, T, U, V W extends keyof>>. Ten splątany stan jest często nazywany „zupą generików” i zamienia inaczej prosty kod w zagadkę, której nikt nie chce się zajmować.

    Trzy nawyki pomagają utrzymać generiki w czytelnej formie, unikając ich splątania. Jednym z powszechnych antypatronów, na które należy uważać, jest wprowadzanie parametru typu, który pojawia się tylko raz w sygnaturze funkcji:

    // ❌ OVER-ENGINEERED: T is only used once
    function logMessage<T extends string>(message: T): void {
      console.log(message);
    }
    
    // ✅ CLEAN & DIRECT: No generic required
    function logMessage(message: string): void {
      console.log(message);
    }
    

    Jeśli parametr typu jest używany tylko raz, zazwyczaj nie uzasadnia swojej złożoności i często można go zastąpić konkretnym typem.

    Podsumowanie: zmiana sposobu myślenia

    Pisanie kodu, który działa tylko dla jednego konkretnego typu danych, czyni z ciebie programistę. To natomiast pisanie kodu, który pozostaje wielokrotnie używalny, komponowalny i bezpieczny pod względem typów dla dowolnego typu danych, odróżnia programistę seniora.

    Generyki odciągają cię od powtarzalnego, kruchego kodu i kierują ku architekturom, które są elastyczne i odporne ze względu na swój projekt. Następnym razem, gdy zauważysz, że kopiujesz interfejs, klonujesz funkcję pomocniczą lub używasz wartości any, zatrzymaj się i zastanów, czy ta wartość nie mogłaby zamiast tego stać się parametrem typu. Gdy ten instynkt stanie się automatyczny, przestaniesz po prostu szybciej pisać w TypeScript i zaczniesz budować systemy, które są w dużej mierze odporne na niespodzianki podczas wykonywania.

    Literatura pokrewna