Skapa robust, typsÀker kod i JavaScript och TypeScript med mönstermatchning, type guards, 'discriminated unions' och fullstÀndighetskontroller. Förhindra körtidsfel.
JavaScript Mönstermatchning och Type Guards: En guide till typsÀker mönstermatchning
I en vÀrld av modern mjukvaruutveckling Àr hantering av komplexa datastrukturer en daglig utmaning. Oavsett om du hanterar API-svar, applikationstillstÄnd eller anvÀndarhÀndelser, arbetar du ofta med data som kan anta en av flera distinkta former. Den traditionella metoden med nÀstlade if-else-satser eller grundlÀggande switch-fall Àr ofta mÄngordig, felbenÀgen och en grogrund för körtidsfel. TÀnk om kompilatorn kunde vara ditt skyddsnÀt och sÀkerstÀlla att du har hanterat varje möjligt scenario?
Det Àr hÀr kraften i typsÀker mönstermatchning kommer in. Genom att lÄna koncept frÄn funktionella programmeringssprÄk som F#, OCaml och Rust, och utnyttja det kraftfulla typsystemet i TypeScript, kan vi skriva kod som inte bara Àr mer uttrycksfull och lÀsbar, utan ocksÄ fundamentalt sÀkrare. Den hÀr artikeln Àr en djupdykning i hur du kan uppnÄ robust, typsÀker mönstermatchning i dina JavaScript- och TypeScript-projekt, och eliminera en hel klass av buggar innan din kod ens körs.
Vad Àr mönstermatchning egentligen?
I grunden Àr mönstermatchning en mekanism för att kontrollera ett vÀrde mot en serie mönster. Det Àr som en superladdad switch-sats. IstÀllet för att bara kontrollera likhet med enkla vÀrden (som strÀngar eller siffror), lÄter mönstermatchning dig kontrollera mot strukturen eller formen pÄ din data.
FörestÀll dig att du sorterar fysisk post. Du kontrollerar inte bara om kuvertet Àr adresserat till "John Doe". Du kan sortera baserat pÄ olika mönster:
- Ăr det ett litet, rektangulĂ€rt kuvert med ett frimĂ€rke? DĂ„ Ă€r det troligen ett brev.
- Ăr det ett stort, vadderat kuvert? DĂ„ Ă€r det sannolikt ett paket.
- Har det ett genomskinligt plastfönster? DÄ Àr det nÀstan sÀkert en rÀkning eller officiell korrespondens.
Mönstermatchning i kod gör samma sak. Det lÄter dig skriva logik som sÀger, "Om min data ser ut sÄ hÀr, gör det dÀr. Om den har den hÀr formen, gör nÄgot annat." Denna deklarativa stil gör din avsikt mycket tydligare Àn en komplex vÀv av imperativa kontroller.
Det klassiska problemet: Den osÀkra switch-satsen
LÄt oss börja med ett vanligt scenario i JavaScript. Vi bygger en grafikapplikation och behöver berÀkna arean av olika former. Varje form Àr ett objekt med en kind-egenskap för att tala om för oss vad det Àr.
// VÄra formobjekt
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// PROBLEM: Ingenting hindrar oss frÄn att komma Ät shape.sideLength hÀr
// och fÄ `undefined`. Detta skulle resultera i NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Denna rena JavaScript-kod fungerar, men den Àr skör. Den lider av tvÄ stora problem:
- Ingen typsÀkerhet: Inuti
'circle'-fallet har JavaScripts körtidsmiljö ingen aning om attshape-objektet garanterat har enradius-egenskap och inte ensideLength. Ett enkelt stavfel somshape.raduiseller ett felaktigt antagande som att komma Ätshape.widthskulle resultera iundefinedoch leda till körtidsfel (somNaNellerTypeError). - Ingen fullstÀndighetskontroll: Vad hÀnder om en ny utvecklare lÀgger till en
Triangle-form? Om de glömmer att uppdateragetArea-funktionen kommer den helt enkelt att returneraundefinedför trianglar, och denna bugg kan gÄ obemÀrkt förbi tills den orsakar problem i en helt annan del av applikationen. Detta Àr ett tyst fel, den farligaste typen av bugg.
Lösning del 1: Grunden med TypeScript's Discriminated Unions
För att lösa dessa problem behöver vi först ett sÀtt att beskriva vÄr "data som kan vara en av flera saker" för typsystemet. TypeScript's Discriminated Unions (Àven kÀnda som 'tagged unions' eller algebraiska datatyper) Àr det perfekta verktyget för detta.
En 'discriminated union' har tre komponenter:
- En uppsÀttning distinkta grÀnssnitt eller typer som representerar varje möjlig variant.
- En gemensam, bokstavlig egenskap (diskriminanten) som finns i alla varianter, som
kind: 'circle'. - En union-typ som kombinerar alla möjliga varianter.
Bygga en `Shape` Discriminated Union
LÄt oss modellera vÄra former med detta mönster:
// 1. Definiera grÀnssnitten för varje variant
interface Circle {
kind: 'circle'; // Diskriminanten
radius: number;
}
interface Square {
kind: 'square'; // Diskriminanten
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // Diskriminanten
width: number;
height: number;
}
// 2. Skapa union-typen
type Shape = Circle | Square | Rectangle;
Med denna Shape-typ har vi talat om för TypeScript att en variabel av typen Shape mÄste vara en Circle, en Square eller en Rectangle. Den kan inte vara nÄgot annat. Denna struktur Àr grundbulten i typsÀker mönstermatchning.
Lösning del 2: Type Guards och kompilatordriven fullstÀndighet
Nu nÀr vi har vÄr 'discriminated union' kan TypeScript's kontrollflödesanalys göra sin magi. NÀr vi anvÀnder en switch-sats pÄ diskriminant-egenskapen (kind), Àr TypeScript smart nog att smalna av typen inom varje case-block. Detta fungerar som en kraftfull, automatisk type guard.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript vet att `shape` Àr en `Circle` hÀr!
// Att komma Ät shape.sideLength skulle vara ett kompileringsfel.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript vet att `shape` Àr en `Square` hÀr!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript vet att `shape` Àr en `Rectangle` hÀr!
return shape.width * shape.height;
}
}
MÀrk den omedelbara förbÀttringen: inuti case 'circle' smalnas typen av shape av frÄn Shape till Circle. Om du försöker komma Ät shape.sideLength kommer din kodredigerare och TypeScript-kompilatorn omedelbart att flagga det som ett fel. Du har eliminerat hela kategorin av körtidsfel orsakade av Ätkomst till felaktiga egenskaper!
UppnÄ verklig sÀkerhet med fullstÀndighetskontroll
Vi har löst typsÀkerhetsproblemet, men hur Àr det med det tysta felet nÀr vi lÀgger till en ny form? Det Àr hÀr vi tvingar fram fullstÀndighetskontroll. Vi sÀger till kompilatorn: "Du mÄste sÀkerstÀlla att jag har hanterat varje enskild möjlig variant av Shape-typen."
Vi kan uppnÄ detta med ett smart knep med hjÀlp av typen never. Typen never representerar ett vÀrde som aldrig ska intrÀffa. Vi lÀgger till ett default-fall i vÄr switch-sats som försöker tilldela shape till en variabel av typen never.
LÄt oss skapa en liten hjÀlpfunktion för detta:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
LÄt oss nu uppdatera vÄr getArea-funktion:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// Om vi har hanterat alla fall kommer `shape` att vara av typen `never` hÀr.
// Om inte, kommer den att vara den ohanterade typen, vilket orsakar ett kompileringsfel.
return assertNever(shape);
}
}
Vid det hÀr laget kompilerar koden perfekt. Men lÄt oss nu se vad som hÀnder nÀr vi introducerar en ny Triangle-form:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// LĂ€gg till den nya formen i unionen
type Shape = Circle | Square | Rectangle | Triangle;
Omedelbart kommer vÄr getArea-funktion att visa ett kompileringsfel i default-fallet:
Argument av typen 'Triangle' kan inte tilldelas till parameter av typen 'never'.
Detta Àr revolutionerande! Kompilatorn agerar nu som vÄrt skyddsnÀt. Den tvingar oss att uppdatera getArea-funktionen för att hantera Triangle-fallet. Den tysta körtidsbuggen har blivit ett högljutt och tydligt kompileringsfel. Genom att ÄtgÀrda felet garanterar vi att vÄr logik Àr komplett.
function getArea(shape: Shape): number { // Nu med korrigeringen
switch (shape.kind) {
// ... andra fall
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // LĂ€gg till det nya fallet
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
NÀr vi lÀgger till case 'triangle' blir default-fallet oÄtkomligt för alla giltiga Shape, typen av shape vid den punkten blir never, felet försvinner och vÄr kod Àr Äterigen komplett och korrekt.
Bortom switch: Deklarativ mönstermatchning med bibliotek
Ăven om switch-satsen med fullstĂ€ndighetskontroll Ă€r otroligt kraftfull kan dess syntax fortfarande kĂ€nnas lite mĂ„ngordig. VĂ€rlden av funktionell programmering har lĂ€nge föresprĂ„kat en mer uttrycksbaserad, deklarativ metod för mönstermatchning. Lyckligtvis erbjuder JavaScript-ekosystemet utmĂ€rkta bibliotek som för denna eleganta syntax till TypeScript, med full typsĂ€kerhet och fullstĂ€ndighet.
Ett av de mest populÀra och kraftfulla biblioteken för detta Àr ts-pattern.
Refaktorering med `ts-pattern`
LÄt oss se hur vÄr getArea-funktion ser ut nÀr den skrivs om med ts-pattern:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // SÀkerstÀller att alla fall hanteras, precis som vÄr `never`-kontroll!
}
Denna metod erbjuder flera fördelar:
- Deklarativ och uttrycksfull: Koden lÀses som en serie regler, som tydligt anger "nÀr indata matchar detta mönster, exekvera denna funktion."
- TypsÀkra callbacks: Notera att i
.with({ kind: 'circle' }, (c) => ...), hÀrleds typen avcautomatiskt och korrekt somCircle. Du fÄr full typsÀkerhet och automatisk komplettering inom callback-funktionen. - Inbyggd fullstÀndighet: Metoden
.exhaustive()tjÀnar samma syfte som vÄrassertNever-hjÀlpfunktion. Om du lÀgger till en ny variant iShape-unionen men glömmer att lÀgga till en.with()-klausul för den, kommerts-patternatt ge ett kompileringsfel. - Det Àr ett uttryck: Hela
match-blocket Àr ett uttryck som returnerar ett vÀrde, vilket gör att du kan anvÀnda det direkt ireturn-satser eller variabeltilldelningar, vilket kan göra koden renare.
Avancerade funktioner i `ts-pattern`
ts-pattern gÄr lÄngt utöver enkel diskriminant-matchning. Det möjliggör otroligt kraftfulla och komplexa mönster.
- Predikatmatchning med
.when(): Du kan matcha baserat pÄ ett villkor. - Wildcard-matchning med
P.any,P.stringetc: Matcha pÄ formen av ett objekt utan en diskriminant. - Standardfall med
.otherwise(): Ger ett rent sÀtt att hantera alla fall som inte explicit matchas, som ett alternativ till.exhaustive().
// Hantera stora kvadrater annorlunda
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Blir:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* sÀrskild logik för stora kvadrater */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Matcha alla objekt som har en numerisk `radius`-egenskap
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Praktiska anvÀndningsfall för en global publik
Detta mönster Àr inte bara för geometriska former. Det Àr otroligt anvÀndbart i mÄnga verkliga programmeringsscenarier som utvecklare över hela vÀrlden stÄr inför dagligen.
1. Hantera tillstÄnd för API-anrop
En vanlig uppgift Àr att hÀmta data frÄn ett API. TillstÄndet för detta anrop kan vanligtvis vara en av flera möjligheter: initial, laddar, lyckad eller fel. En 'discriminated union' Àr perfekt för att modellera detta.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// I din UI-komponent (t.ex. React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => VÀlkommen! Klicka pÄ en knapp för att ladda din profil.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Med detta mönster Àr det omöjligt att av misstag rendera en anvÀndarprofil nÀr tillstÄndet fortfarande laddas, eller att försöka komma Ät state.data nÀr statusen Àr error. Kompilatorn garanterar den logiska konsistensen i ditt anvÀndargrÀnssnitt.
2. TillstÄndshantering (t.ex. Redux, Zustand)
Inom tillstÄndshantering skickar du 'actions' för att uppdatera applikationens tillstÄnd. Dessa 'actions' Àr ett klassiskt anvÀndningsfall för 'discriminated unions'.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` Àr korrekt typad hÀr!
// ... logik för att lÀgga till objekt
return { ...state, /* updated items */ };
case 'REMOVE_ITEM':
// ... logik för att ta bort objekt
return { ...state, /* updated items */ };
// ... och sÄ vidare
default:
return assertNever(action);
}
}
NÀr en ny 'action'-typ lÀggs till i CartAction-unionen kommer cartReducer att misslyckas med att kompilera tills den nya 'action' hanteras, vilket förhindrar att du glömmer att implementera dess logik.
3. Bearbeta hÀndelser
Oavsett om du hanterar WebSocket-hÀndelser frÄn en server eller anvÀndarinteraktionshÀndelser i en komplex applikation, erbjuder mönstermatchning ett rent, skalbart sÀtt att dirigera hÀndelser till rÀtt hanterare.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`User ${e.userId} logged in.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Unhandled event: ${e.event}`));
}
Fördelarna sammanfattade
- SkottsÀker typsÀkerhet: Du eliminerar en hel klass av körtidsfel relaterade till felaktiga dataformer (t.ex.
Cannot read properties of undefined). - Tydlighet och lÀsbarhet: Den deklarativa naturen hos mönstermatchning gör programmerarens avsikt uppenbar, vilket leder till kod som Àr lÀttare att lÀsa och förstÄ.
- Garanterad fullstÀndighet: FullstÀndighetskontroll förvandlar kompilatorn till en vaksam partner som sÀkerstÀller att du har hanterat varje möjlig datavariant.
- Enkel refaktorering: Att lÀgga till nya varianter i dina datamodeller blir en sÀker, guidad process. Kompilatorn kommer att peka ut varje enskild plats i din kodbas som behöver uppdateras.
- Minskad 'boilerplate'-kod: Bibliotek som
ts-patternerbjuder en koncis, kraftfull och elegant syntax som ofta Àr mycket renare Àn traditionella kontrollflödessatser.
Slutsats: Omfamna förtroendet vid kompilering
Att gĂ„ frĂ„n traditionella, osĂ€kra kontrollflödesstrukturer till typsĂ€ker mönstermatchning Ă€r ett paradigmskifte. Det handlar om att flytta kontroller frĂ„n körtid, dĂ€r de manifesteras som buggar för dina anvĂ€ndare, till kompileringstid, dĂ€r de visas som hjĂ€lpsamma fel för dig, utvecklaren. Genom att kombinera TypeScript's 'discriminated unions' med kraften i fullstĂ€ndighetskontroll â antingen genom en manuell never-kontroll eller ett bibliotek som ts-pattern â kan du bygga applikationer som Ă€r mer robusta, underhĂ„llbara och motstĂ„ndskraftiga mot förĂ€ndringar.
NÀsta gÄng du skriver en lÄng if-else if-else-kedja eller en switch-sats pÄ en strÀngegenskap, ta en stund att fundera pÄ om du kan modellera din data som en 'discriminated union'. Gör investeringen i typsÀkerhet. Ditt framtida jag, och din globala anvÀndarbas, kommer att tacka dig för den stabilitet och tillförlitlighet det ger din programvara.