Dowiedz się, jak osiągnąć bezpieczne typowo i zweryfikowane w czasie kompilacji dopasowywanie wzorców w JavaScript za pomocą TypeScript, unii rozłącznych i nowoczesnych bibliotek.
JavaScript: Dopasowywanie wzorców i bezpieczeństwo typów: Przewodnik po weryfikacji w czasie kompilacji
Dopasowywanie wzorców to jedna z najpotężniejszych i najbardziej ekspresyjnych cech nowoczesnego programowania, od dawna ceniona w językach funkcyjnych, takich jak Haskell, Rust i F#. Pozwala programistom dekonstruować dane i wykonywać kod w oparciu o ich strukturę w sposób, który jest zarówno zwięzły, jak i niezwykle czytelny. Wraz z ewolucją JavaScript, programiści coraz częściej chcą przyjmować te potężne paradygmaty. Pozostaje jednak istotne wyzwanie: jak osiągnąć solidne bezpieczeństwo typów i gwarancje czasu kompilacji tych języków w dynamicznym świecie JavaScript?
Odpowiedź tkwi w wykorzystaniu statycznego systemu typów TypeScript. Podczas gdy sam JavaScript powoli zbliża się do natywnego dopasowywania wzorców, jego dynamiczna natura oznacza, że wszelkie kontrole odbywałyby się w czasie wykonywania, potencjalnie prowadząc do nieoczekiwanych błędów w środowisku produkcyjnym. Ten artykuł jest szczegółowym omówieniem technik i narzędzi, które umożliwiają prawdziwą weryfikację wzorców w czasie kompilacji, zapewniając, że wychwycisz błędy nie wtedy, gdy zrobią to Twoi użytkownicy, ale wtedy, gdy piszesz kod.
Zbadamy, jak budować solidne, samodokumentujące się i odporne na błędy systemy, łącząc potężne funkcje TypeScript z elegancją dopasowywania wzorców. Przygotuj się na wyeliminowanie całej klasy błędów czasu wykonywania i pisanie kodu, który jest bezpieczniejszy i łatwiejszy w utrzymaniu.
Czym właściwie jest dopasowywanie wzorców?
U podstaw dopasowywanie wzorców to wyrafinowany mechanizm kontroli przepływu. Jest to jak turbodoładowana instrukcja `switch`. Zamiast tylko sprawdzać równość z prostymi wartościami (takimi jak liczby lub ciągi znaków), dopasowywanie wzorców pozwala sprawdzić wartość z złożonymi "wzorcami", a jeśli zostanie znalezione dopasowanie, powiązać zmienne z częściami tej wartości.
Porównajmy to z tradycyjnymi podejściami:
Stara metoda: łańcuchy `if-else` i `switch`
Rozważmy funkcję, która oblicza pole powierzchni figury geometrycznej. Przy tradycyjnym podejściu twój kod może wyglądać tak:
// Shape is an object with a 'type' property
function calculateArea(shape) {
if (shape.type === 'circle') {
return Math.PI * shape.radius * shape.radius;
} else if (shape.type === 'square') {
return shape.sideLength * shape.sideLength;
} else if (shape.type === 'rectangle') {
return shape.width * shape.height;
} else {
throw new Error('Unsupported shape type');
}
}
To działa, ale jest rozwlekłe i podatne na błędy. Co się stanie, jeśli dodasz nowy kształt, taki jak `triangle`, ale zapomnisz zaktualizować tę funkcję? Kod zgłosi ogólny błąd w czasie wykonywania, który może być daleki od miejsca, w którym wprowadzono rzeczywisty błąd.
Dopasowywanie wzorców: deklaratywne i ekspresywne
Dopasowywanie wzorców przekształca tę logikę w bardziej deklaratywną. Zamiast serii imperatywnych sprawdzeń, deklarujesz wzorce, których oczekujesz, i działania, które należy podjąć:
// Pseudocode for a future JavaScript pattern matching feature
function calculateArea(shape) {
match (shape) {
when ({ type: 'circle', radius }): return Math.PI * radius * radius;
when ({ type: 'square', sideLength }): return sideLength * sideLength;
when ({ type: 'rectangle', width, height }): return width * height;
default: throw new Error('Unsupported shape type');
}
}
Kluczowe korzyści są natychmiast widoczne:
- Destrukturyzacja: Wartości takie jak `radius`, `width` i `height` są automatycznie wyodrębniane z obiektu `shape`.
- Czytelność: Intencja kodu jest jaśniejsza. Każda klauzula `when` opisuje konkretną strukturę danych i odpowiadającą jej logikę.
- Wyczerpanie: Jest to najważniejsza korzyść dla bezpieczeństwa typów. Naprawdę solidny system dopasowywania wzorców może ostrzec Cię w czasie kompilacji, jeśli zapomniałeś obsłużyć możliwy przypadek. To jest nasz główny cel.
Wyzwanie JavaScript: dynamizm kontra bezpieczeństwo
Największa siła JavaScript – jego elastyczność i dynamiczna natura – jest również jego największą słabością, jeśli chodzi o bezpieczeństwo typów. Bez statycznego systemu typów egzekwującego kontrakty w czasie kompilacji, dopasowywanie wzorców w czystym JavaScript ogranicza się do sprawdzeń w czasie wykonywania. To znaczy:
- Brak gwarancji czasu kompilacji: Nie będziesz wiedział, że pominąłeś przypadek, dopóki twój kod nie zostanie uruchomiony i nie trafi na tę konkretną ścieżkę.
- Ciche awarie: Jeśli zapomnisz o domyślnym przypadku, niezgodna wartość może po prostu spowodować `undefined`, powodując subtelne błędy w dalszej części kodu.
- Koszmary refaktoryzacji: Dodanie nowego wariantu do struktury danych (np. nowego typu zdarzenia, nowego statusu odpowiedzi API) wymaga globalnego wyszukiwania i zamiany, aby znaleźć wszystkie miejsca, w których należy go obsłużyć. Pominięcie jednego może zepsuć aplikację.
To tutaj TypeScript całkowicie zmienia grę. Jego statyczny system typów pozwala nam precyzyjnie modelować nasze dane, a następnie wykorzystywać kompilator do egzekwowania, że obsługujemy każdą możliwą wariację. Zobaczmy, jak.
Technika 1: Podstawa z uniami rozłącznymi
Najważniejszą cechą TypeScript, która umożliwia bezpieczne typowo dopasowywanie wzorców, jest unia rozłączna (znana również jako unia oznaczona lub algebraiczny typ danych). Jest to potężny sposób modelowania typu, który może być jedną z kilku różnych możliwości.
Czym jest unia rozłączna?
Unia rozłączna składa się z trzech składników:
- Zbiór różnych typów (elementy unii).
- Wspólna właściwość z typem literału, znanym jako dyskryminator lub tag. Ta właściwość pozwala TypeScript zawęzić konkretny typ w unii.
- Typ unii, który łączy wszystkie typy elementów.
Przemodelujmy nasz przykład kształtu za pomocą tego wzorca:
// 1. Define the distinct member types
interface Circle {
kind: 'circle'; // The discriminant
radius: number;
}
interface Square {
kind: 'square'; // The discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // The discriminant
width: number;
height: number;
}
// 2. Create the union type
type Shape = Circle | Square | Rectangle;
Teraz zmienna typu `Shape` musi być jednym z tych trzech interfejsów. Właściwość `kind` działa jak klucz, który odblokowuje możliwości zawężania typów TypeScript.
Implementacja sprawdzania wyczerpania w czasie kompilacji
Mając już naszą unię rozłączną, możemy teraz napisać funkcję, która zgodnie z gwarancją kompilatora obsłuży każdy możliwy kształt. Magicznym składnikiem jest typ `never` TypeScript, który reprezentuje wartość, która nigdy nie powinna wystąpić.
Możemy napisać prostą funkcję pomocniczą, aby to wymusić:
function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}
Teraz przepiszmy naszą funkcję `calculateArea` za pomocą standardowej instrukcji `switch`. Zobacz, co się dzieje w przypadku `default`:
function calculateArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows `shape` is a Circle here!
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows `shape` is a Square here!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows `shape` is a Rectangle here!
return shape.width * shape.height;
default:
// If we've handled all cases, `shape` will be of type `never`
return assertUnreachable(shape);
}
}
Ten kod kompiluje się idealnie. Wewnątrz każdego bloku `case` TypeScript zawęził typ `shape` do `Circle`, `Square` lub `Rectangle`, umożliwiając nam bezpieczny dostęp do właściwości, takich jak `radius`.
A teraz magiczny moment. Wprowadźmy nowy kształt do naszego systemu:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
type Shape = Circle | Square | Rectangle | Triangle; // Add it to the union
Gdy tylko dodamy `Triangle` do unii `Shape`, nasza funkcja `calculateArea` natychmiast wygeneruje błąd czasu kompilacji:
// In the `default` block of `calculateArea`:
return assertUnreachable(shape);
// ~~~~~
// Argument of type 'Triangle' is not assignable to parameter of type 'never'.
Ten błąd jest niezwykle cenny. Kompilator TypeScript mówi nam: „Obiecałeś obsłużyć każdy możliwy `Shape`, ale zapomniałeś o `Triangle`. Zmienna `shape` nadal może być `Triangle` w przypadku domyślnym, a to nie jest przypisywalne do `never`”.
Aby naprawić błąd, po prostu dodajemy brakujący przypadek. Kompilator staje się naszą siatką bezpieczeństwa, gwarantując, że nasza logika pozostanie zsynchronizowana z naszym modelem danych.
// ... inside the switch
case 'triangle':
return 0.5 * shape.base * shape.height;
default:
return assertUnreachable(shape);
// ... now the code compiles again!
Zalety i wady tego podejścia
- Zalety:
- Zero zależności: Używa tylko podstawowych funkcji TypeScript.
- Maksymalne bezpieczeństwo typów: Zapewnia żelazne gwarancje czasu kompilacji.
- Doskonała wydajność: Kompiluje się do wysoce zoptymalizowanej standardowej instrukcji `switch` JavaScript.
- Wady:
- Rozwlekłość: `switch`, `case`, `break`/`return` i `default` mogą wydawać się uciążliwe.
- Nie jest wyrażeniem: Instrukcja `switch` nie może być bezpośrednio zwracana ani przypisywana do zmiennej, co prowadzi do bardziej imperatywnych stylów kodowania.
Technika 2: Ergonomiczne API z nowoczesnymi bibliotekami
Chociaż unia rozłączna z instrukcją `switch` jest podstawą, jej boilerplate może być nużący. Doprowadziło to do powstania fantastycznych bibliotek open source, które zapewniają bardziej funkcjonalne, ekspresywne i ergonomiczne API do dopasowywania wzorców, jednocześnie wykorzystując kompilator TypeScript dla bezpieczeństwa.
Przedstawiamy `ts-pattern`
Jedną z najpopularniejszych i najpotężniejszych bibliotek w tej przestrzeni jest `ts-pattern`. Pozwala zastąpić instrukcje `switch` płynnym, łańcuchowym API, które działa jako wyrażenie.
Przepiszmy naszą funkcję `calculateArea` za pomocą `ts-pattern`:
import { match } from 'ts-pattern';
function calculateAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (s) => Math.PI * s.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (s) => s.width * s.height)
.with({ kind: 'triangle' }, (s) => 0.5 * s.base * s.height)
.exhaustive(); // This is the key to compile-time safety
}
Rozłóżmy to na czynniki pierwsze:
- `match(shape)`: To rozpoczyna wyrażenie dopasowywania wzorców, biorąc wartość do dopasowania.
- `.with({ kind: '...' }, handler)`: Każde wywołanie `.with()` definiuje wzorzec. `ts-pattern` jest na tyle sprytny, aby wywnioskować typ drugiego argumentu (funkcja `handler`). Dla wzorca `{ kind: 'circle' }` wie, że dane wejściowe `s` do obsługi będą typu `Circle`.
- `.exhaustive()`: Ta metoda jest odpowiednikiem naszej sztuczki `assertUnreachable`. Mówi `ts-pattern`, że wszystkie możliwe przypadki muszą być obsłużone. Jeśli usunęlibyśmy linię `.with({ kind: 'triangle' }, ...)`, `ts-pattern` wywołałby błąd w czasie kompilacji podczas wywołania `.exhaustive()`, informując nas, że dopasowanie nie jest wyczerpujące.
Zaawansowane funkcje `ts-pattern`
`ts-pattern` wykracza daleko poza proste dopasowywanie właściwości:
- Dopasowywanie predykatów za pomocą `.when()`: Dopasuj na podstawie warunku.
match(input) .when(isString, (str) => `It's a string: ${str}`) .when(isNumber, (num) => `It's a number: ${num}`) .otherwise(() => 'It is something else'); - Głęboko zagnieżdżone wzorce: Dopasuj do złożonych struktur obiektów.
match(user) .with({ address: { city: 'Paris' } }, () => 'User is in Paris') .otherwise(() => 'User is elsewhere'); - Symbole wieloznaczne i specjalne selektory: Użyj `P.select()` do przechwycenia wartości w obrębie wzorca lub `P.string`, `P.number` do dopasowania dowolnej wartości określonego typu.
import { match, P } from 'ts-pattern'; match(event) .with({ type: 'USER_LOGIN', user: { name: P.select() } }, (name) => { console.log(`${name} logged in.`); }) .otherwise(() => {});
Używając biblioteki takiej jak `ts-pattern`, otrzymujesz to, co najlepsze z obu światów: solidne bezpieczeństwo czasu kompilacji dzięki sprawdzaniu `never` TypeScript, połączone z czystym, deklaratywnym i wysoce ekspresyjnym API.
Przyszłość: propozycja dopasowywania wzorców TC39
Sam język JavaScript jest na ścieżce do uzyskania natywnego dopasowywania wzorców. Istnieje aktywna propozycja w TC39 (komitet, który standaryzuje JavaScript), aby dodać wyrażenie `match` do języka.
Proponowana składnia
Składnia prawdopodobnie będzie wyglądać mniej więcej tak:
// This is proposed JavaScript syntax and might change
const getMessage = (response) => {
return match (response) {
when ({ status: 200, body: b }) { return `Success with body: ${b}`; }
when ({ status: 404 }) { return 'Not Found'; }
when ({ status: s if s >= 500 }) { return `Server Error: ${s}`; }
default { return 'Unknown response'; }
}
};
Co z bezpieczeństwem typów?
To jest kluczowe pytanie w naszej dyskusji. Sam w sobie natywny element dopasowywania wzorców JavaScript wykonywałby swoje sprawdzenia w czasie wykonywania. Nie wiedziałby o twoich typach TypeScript.
Jednak jest prawie pewne, że zespół TypeScript zbudowałby analizę statyczną na podstawie tej nowej składni. Tak jak TypeScript analizuje instrukcje `if` i bloki `switch`, aby zawęzić typ, tak samo analizowałby wyrażenia `match`. Oznacza to, że ostatecznie moglibyśmy uzyskać najlepszy możliwy wynik:
- Natywna, wydajna składnia: Nie ma potrzeby stosowania bibliotek ani sztuczek transpilacji.
- Pełne bezpieczeństwo czasu kompilacji: TypeScript sprawdziłby wyrażenie `match` pod kątem wyczerpania w odniesieniu do unii rozłącznej, tak jak robi to dzisiaj w przypadku `switch`.
Czekając, aż ta funkcja przejdzie przez etapy propozycji i trafi do przeglądarek i środowisk uruchomieniowych, techniki, które omówiliśmy dzisiaj z uniami rozłącznymi i bibliotekami, są gotowym do produkcji, najnowocześniejszym rozwiązaniem.
Praktyczne zastosowania i najlepsze praktyki
Zobaczmy, jak te wzorce mają zastosowanie do typowych, rzeczywistych scenariuszy programistycznych.
Zarządzanie stanem (Redux, Zustand itp.)
Zarządzanie stanem za pomocą akcji jest idealnym przypadkiem użycia dla unii rozłącznych. Zamiast używać stałych ciągów znaków dla typów akcji, zdefiniuj unię rozłączną dla wszystkich możliwych akcji.
// Define actions
interface IncrementAction { type: 'counter/increment'; payload: number; }
interface DecrementAction { type: 'counter/decrement'; payload: number; }
interface ResetAction { type: 'counter/reset'; }
type CounterAction = IncrementAction | DecrementAction | ResetAction;
// A type-safe reducer
function counterReducer(state: number, action: CounterAction): number {
return match(action)
.with({ type: 'counter/increment' }, (act) => state + act.payload)
.with({ type: 'counter/decrement' }, (act) => state - act.payload)
.with({ type: 'counter/reset' }, () => 0)
.exhaustive();
}
Teraz, jeśli dodasz nową akcję do unii `CounterAction`, TypeScript zmusi Cię do zaktualizowania reduktora. Nigdy więcej zapomnianych obsługi akcji!
Obsługa odpowiedzi API
Pobieranie danych z API obejmuje wiele stanów: ładowanie, sukces i błąd. Modelowanie tego za pomocą unii rozłącznej sprawia, że logika interfejsu użytkownika jest znacznie bardziej solidna.
// Model the async data state
type RemoteData =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: E };
// In your UI component (e.g., React)
function UserProfile({ userId }: { userId: string }) {
const [userState, setUserState] = useState>({ status: 'idle' });
// ... useEffect to fetch data and update state ...
return match(userState)
.with({ status: 'idle' }, () => Click a button to load the user.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (state) => )
.with({ status: 'error' }, (state) => )
.exhaustive();
}
To podejście gwarantuje, że zaimplementowałeś interfejs użytkownika dla każdego możliwego stanu pobierania danych. Nie możesz przypadkowo zapomnieć o obsłudze przypadku ładowania lub błędu.
Podsumowanie najlepszych praktyk
- Modeluj za pomocą unii rozłącznych: Zawsze, gdy masz wartość, która może mieć jeden z kilku różnych kształtów, użyj unii rozłącznej. Jest to podstawa bezpiecznych typowo wzorców w TypeScript.
- Zawsze wymuszaj wyczerpanie: Niezależnie od tego, czy używasz sztuczki `never` z instrukcją `switch`, czy metody `.exhaustive()` biblioteki, nigdy nie pozostawiaj dopasowania wzorca otwartego. Stąd pochodzi bezpieczeństwo.
- Wybierz odpowiednie narzędzie: W prostych przypadkach instrukcja `switch` jest w porządku. W przypadku złożonej logiki, zagnieżdżonego dopasowywania lub bardziej funkcjonalnego stylu, biblioteka taka jak `ts-pattern` znacznie poprawi czytelność i zmniejszy ilość boilerplate.
- Utrzymuj czytelność wzorców: Celem jest jasność. Unikaj zbyt złożonych, zagnieżdżonych wzorców, które są trudne do zrozumienia na pierwszy rzut oka. Czasami lepszym podejściem jest podzielenie dopasowania na mniejsze funkcje.
Podsumowanie: Pisanie przyszłości bezpiecznego JavaScript
Dopasowywanie wzorców to coś więcej niż tylko lukier składniowy; to paradygmat, który prowadzi do bardziej deklaratywnego, czytelnego i – co najważniejsze – bardziej solidnego kodu. Chociaż z niecierpliwością czekamy na jego natywne nadejście w JavaScript, nie musimy czekać, aby czerpać z niego korzyści.
Wykorzystując moc statycznego systemu typów TypeScript, szczególnie z uniami rozłącznymi, możemy budować systemy, które można zweryfikować w czasie kompilacji. To podejście zasadniczo przenosi wykrywanie błędów z czasu wykonywania na czas programowania, oszczędzając niezliczone godziny debugowania i zapobiegając incydentom produkcyjnym. Biblioteki takie jak `ts-pattern` bazują na tej solidnej podstawie, zapewniając eleganckie i potężne API, które sprawia, że pisanie bezpiecznego typowo kodu jest radością.
Przyjęcie weryfikacji wzorców w czasie kompilacji to krok w kierunku pisania bardziej odpornych i łatwych w utrzymaniu aplikacji. Zachęca do wyraźnego myślenia o wszystkich możliwych stanach, w jakich mogą znajdować się twoje dane, eliminując dwuznaczność i sprawiając, że logika twojego kodu jest krystalicznie czysta. Zacznij modelować swoją domenę za pomocą unii rozłącznych już dziś i pozwól, aby kompilator TypeScript był twoim niestrudzonym partnerem w budowaniu oprogramowania bez błędów.