Enda mer fornøyd med TypeScript satisfies
Når vi skriver TypeScript er det lett å tenke at jo flere typer vi lager, jo bedre. Flere typer kan bety bedre kontroll over koden, og at du hindrer feil å oppstå tidlig. På samme tid kan det å annotere typer for tidlig også gjøre at TypeScript mister nyttig informasjon som allerede finnes i variablene. Det stemte hvertfall godt frem til november 2022. Da ble TypeScript versjon 4.9 lansert, og satisfies operatoren introdusert. I denne bloggposten skal vi se på noen konkrete bruksområder for satisfies operatoren, og jeg håper du sitter igjen med et nytt verktøy, eller en litt bedre magefølelse for hvordan satisfies fungerer.
Den vanligste måten å gi typer til en variabel i TypeScript er ved typeannoteringer. Her setter vi typen direkte på variabelen, og innholdet i variabelen er nødt til å oppfylle strukturen til typen.
Vi starter med et eksempel på typeannotering for en variabel som inneholder menyelementer. Vi gir hvert element et navn, en URL-sti og en valgfri liste av barneelementer:
interface MenuItem {
label: string
path: `/${string}`
icon: 'home' | 'account' | 'feed'
}
const menuItems: Record<string, MenuItem> = {
hjem: { label: 'Hjem', path: '/', icon: 'home' },
profil: { label: 'Profil', path: '/profil', icon: 'account' },
nyheter: { label: 'Nyheter', path: '/nyheter', icon: 'feed' },
}Ved å bruke typeannotering har vi sikret at alle menyelementene har en kort beskrivelse, en URL-sti som i hvert fall starter med skråstrek og et gyldig ikon som er tilgjengelig i appen.
Nå som vi har definert menyelementene hadde det vært nyttig med en type som vet hvilke gyldige menyelementer som finnes, som i dette tilfellet er "hjem", "profil" og "nyheter". Vi prøver å utlede dette ved å bruke nøkkelordene keyof og typeof.
type MenuItemKeys = keyof typeof menuItems
// string 😥Dessverre klarer ikke TypeScript å utlede noe annet enn en generisk string. Ved å annotere objektet som Record<string, MenuItem> forteller vi TypeScript at alle strenger er gyldige nøkler. Da mister vi samtidig informasjonen om de faktiske nøklene som er definert i objektet, og typen blir en generisk string.
En løsning for å få utledet menyelementene kan være å ta bort den forhåndsdefinerte typen, og i stedet bruke as const, som gjør elementet readonly, og tillater TypeScript å gi en mer spesifikk versjon av objektet.
const menuItems = {
hjem: { label: 'Hjem', path: '/', icon: 'home' },
profil: { label: 'Profil', path: '/profil', icon: 'account' },
nyheter: { label: 'Nyheter', path: '/nyheter', icon: 'feed' },
} as const
type MenuItemKeys = keyof typeof menuItems
// "hjem" | "profil" | "nyheter" 😍
type MenuItemPaths = (typeof menuItems)[MenuItemKeys]['path']
// "/" | "/profil" | "/nyheter" 😍Nå får vi utledet både gyldige nøkler og gyldige URL-stier. Supert!
NB: nøklene kan vi også utlede uten å legge på as const, men for å utlede URL-stiene, som ligger et nivå dypere i objektet, må vi ha as const for å ikke ende opp med en generisk string-type.
Har vi mistet noe på veien?
I eksempelet over med as const så får vi utledet nøkler og URL-stier, men vi mangler annoteringen som krever at typen oppfyller kravet til MenuItem. For å oppfylle strukturen må URL-stier begynne med skråstrek, og ikonene må være et av de gyldige ikon-navnene.
Kort oppsummert står vi mellom to valg:
- Annotere med type og miste spesifikk inferens
- Bruke
as constog miste struktursjekk
Spørsmålet er: finnes det en måte å få begge deler?
TypeScript 'satisfies'
Operatoren satisfies ble introdusert i november 2022 med TypeScript versjon 4.9. I den offisielle introduksjonen står det følgende: "TypeScript developers are often faced with a dilemma: we want to ensure that some expression matches some type, but also want to keep the most specific type of that expression for inference purposes" [1]. Her får vi altså både i pose og sekk, spesifikk typeinferens og struktursjekk samtidig. Gode nyheter!
Vi kan ta dette i bruk på eksempelvariabelen vår med menyelementer på følgende måte:
const menuItems = {
hjem: { label: 'Hjem', path: '/', icon: 'home' },
profil: { label: 'Profil', path: '/profil', icon: 'account' },
nyheter: { label: 'Nyheter', path: '/nyheter', icon: 'feed' },
} satisfies Record<string, MenuItem>I stedet for å starte med å annotere variabelen med en type, så gjør vi det til slutt ved å legge på satisfies Record<string, MenuItem>.
Det er viktig å merke seg at satisfies ikke endrer typen på variabelen. Den brukes kun til å verifisere at uttrykket kan oppfylle en gitt type, uten å påvirke typeinferensen videre.
Prøver vi nå å utlede typer basert på nøklene og URL-stiene, beholder vi den samme spesifikke typeinferensen som med as const:
type MenuItemKeys = keyof typeof menuItems
// "hjem" | "profil" | "nyheter" 😍
type MenuItemPaths = (typeof menuItems)[MenuItemKeys]['path']
// "/" | "/profil" | "/nyheter" 😍
Endrer vi objektet slik at det ikke lenger oppfyller kravene, gir TypeScript tydelig beskjed:
const menuItems = {
hjem: { label: 'Hjem', path: '/', icon: 'home' },
profil: { label: 'Profil', path: '/profil', icon: 'acount' },
// Type '"acount"' is not assignable to type '"home" | "account" | "feed" | undefined'.
nyheter: { label: 'Nyheter', path: 'nyheter', icon: 'feed' },
// Type '"nyheter"' is not assignable to type '`/${string}`'
} satisfies Record<string, MenuItem>Når skal jeg bruke satisfies?
Et vanlig bruksområde for satisfies operatoren er når du har statiske data i applikasjonen din, slik som i eksempelet med menyelementer. Her er jeg interessert i både strukturen og innholdet i variabelen. På den andre siden, henter du data fra et eksternt API så bryr du deg om hvilken struktur dataen har, men ikke hvilke verdier dataen har før du kjører applikasjonen. Henter jeg værdata fra en ekstern tjeneste, bryr jeg meg om at dataen følger strukturen { temperature: string, timestamp: Date }. Om temperaturen er 4.2 °C eller -2.1 °C vet jeg først når applikasjonen kjører, og det påvirker uansett ikke typene i TypeScript.
Et annet praktisk eksempel er definisjon av applikasjonens URL-stier med query-parametre. Siden disse er kjent allerede ved build time, passer satisfies godt her:
type PathWithQuery = `${NestedPath}${'?' | ''}${string}`
const ROUTES = {
user: {
detail: '/users/:id',
posts: '/users/:id/posts?sort=date&limit=10',
postDetail: '/users/:id/posts/:postId/comments/:commentId',
},
admin: {
dashboard: '/admin/dashboard?period=month&view=analytics',
users: '/admin/users/:userId/activity?days=30',
},
} satisfies Record<
string,
PathWithQuery | Record<string, PathWithQuery>
>Her får vi både autocomplete i IDE og spesifikke URL-stier i variabelen, selv med flere nivåer i objektet.
Midlertidig typesjekk i satisfies
Når du skriver funksjoner i TypeScript, er det ofte fornuftig å starte med å spesifisere tydelige typer for både input og output. Det fungerer som en kontrakt som gjør koden enklere å forstå og bruke riktig.
Utfordringene kan oppstå når funksjonen gjør større transformasjoner, omrokkeringer eller beregninger av data. Hvis det da oppstår en feil, kan feilmeldingen vises langt unna stedet der feilen faktisk introduseres. I tilfeller som dette kan det være nyttig å lagre et vellykket sjekkpunkt i koden, som forteller at "dette avsnittet har korrekt struktur".
Jeg skal prøve å vise hva jeg mener med et eksempel som håndterer varslinger for værdata. Vi starter med å definere en discriminated union som skiller mellom aktive og avsluttede varslinger:
type ActiveAlert = {
status: 'active'
severity: 'minor' | 'moderate' | 'severe' | 'extreme'
startTime: Date
}
type CompletedAlert = {
status: 'completed'
severity: 'minor' | 'moderate' | 'severe' | 'extreme'
startTime: Date
endTime: Date
duration: number
}
type WeatherAlert = ActiveAlert | CompletedAlertAktive varsler har info om alvorlighetsgrad og starttidspunkt. Avsluttede varsler har i tillegg et sluttidspunkt og en varighet.
Videre har vi laget en funksjon som henter værvarslinger, og kategoriserer dem som aktive eller avsluttede.
async function getWeatherAlerts(
locationIds: string[],
): Promise<WeatherAlert[]> {
const alertData = await fetchWeatherAlerts(locationIds)
const alerts = alertData.flatMap((location) =>
location.alerts.map((alert) => {
if (alert.endTime === null) {
return {
status: 'active' as const,
severity: alert.severity,
startTime: alert.endTime,
}
}
return {
status: 'completed' as const,
severity: alert.severity,
startTime: alert.startTime,
endTime: alert.endTime,
duration: TimeUtil.hoursBetween(
alert.startTime,
alert.endTime,
),
}
}),
)
return alerts
}Koden over henter ut værdata med funksjonen fetchWeatherAlerts, før den transformerer og kategoriserer hendelsen som en aktiv eller avsluttet hendelse.
Hvis du leser koden nøye ser du kanskje at jeg har lagt inn en feil. Dersom du kjører en typesjekk vil du få følgende feilmelding på linja som returnerer varslingene:
Type '({ status: "active"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: null; endTime?: undefined; duration?: undefined; } | { status: "completed"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: Date; endTime: Date; duration: number; })[]' is not assignable to type 'WeatherAlert[]'.
Type '{ status: "active"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: null; endTime?: undefined; duration?: undefined; } | { status: "completed"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: Date; endTime: Date; duration: number; }' is not assignable to type 'WeatherAlert'.
Type '{ status: "active"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: null; endTime?: undefined; duration?: undefined; }' is not assignable to type 'WeatherAlert'.
Type '{ status: "active"; severity: "minor" | "moderate" | "severe" | "extreme"; startTime: null; endTime?: undefined; duration?: undefined; }' is not assignable to type 'ActiveAlert'.
Types of property 'startTime' are incompatible.
Type 'null' is not assignable to type 'Date'.Feilmeldingen er både lang og vanskelig å lese. Det som feiler er at returverdien for aktive hendelser potensielt sender inn null som starttidspunkt. Helt nederst i feilmeldingen står det at startTime ikke kan være null.
En fin praksis på større funksjoner som dette synes jeg derfor er å typesjekke de ulike hendelsene som aktive eller avsluttede hendelser der vi lager dem, siden da vil feilmeldingen ligge mye nærmere rotårsaken. Dersom vi bruker satisfies så kan vi tvinge feilmeldingen til å bli lokal, og som en bonus så vil satisfies gjøre typen spesifikk og vi slipper å bruke as const på statusfeltet for å gå fra generisk string til forhåndsdefinert status.
// ...
return {
status: 'active',
severity: alert.severity,
startTime: alert.endTime,
// Type 'null' is not assignable to type 'Date'.ts(2322)
} satisfies ActiveAlert
return {
status: 'completed',
severity: alert.severity,
startTime: alert.startTime,
endTime: alert.endTime,
duration: TimeUtil.hoursBetween(alert.startTime, alert.endTime),
} satisfies CompletedAlert
// ...Nå får vi en mye kortere, og mer presis feilmelding, direkte der feilen oppstår: Type 'null' is not assignable to type 'Date'.ts(2322).
En alternativ løsning for å få samme feilmelding er å lage en variabel og eksplisitt gi den typen ActiveAlert:
const activeAlert: ActiveAlert = {
status: 'active',
severity: alert.severity,
startTime: alert.endTime,
}
return activeAlertDette er en god løsning, men i noen tilfeller kan det oppleves unødvendig å trekke ut en variabel kun for å gi den riktig type.
Fullstendighetssjekk med 'satisfies never'
Et triks for å sjekke at vi har håndtert alle varianter i en switch-case er å returnere typen never i default blokken. I eksempelet under så prøver vi å si at parameteren status oppfyller kravene til TypeScript typen never, og siden det aldri er lov å tilegne en variabel til never så gir TypeScript oss beskjed om at vi har glemt å håndtere statusene idle og success.
type ApiStatus = 'loading' | 'error' | 'success' | 'idle'
const handleApiStatus = (status: ApiStatus) => {
switch (status) {
case 'loading':
// do stuff
break
case 'error':
// do stuff
break
default:
status satisfies never
// Type '"idle" | "success"' does not satisfy the expected type 'never'.
}
}Dette kan også løses med en egen assertNever-funksjon, men satisfies never har fordelen av å være mer direkte og uten ekstra hjelpefunksjoner.
Trenger jeg å bruke satisfies?
Det kommer i stor grad an på hvordan du strukturerer koden din, og om du har statisk kode definert i TypeScript eller ikke. satisfies er ikke ment som en erstatning for vanlige typeannoteringer, men som et supplement når disse ikke strekker helt til.
Vi har sett på noen bruksområder for satisfies som fullstendighetssjekk i switch-case, og hvordan man kan få en mer lesbar feilmelding med satisfies som midlertidig sjekkpunkt. Likevel er det slik jeg ser det en grunn som veier tyngst for å bruke satisfies i koden: du får både typesjekk og detaljert typeinferens samtidig. Så, ta gjerne satisfies i bruk i dag, og bli enda mer fornøyd med å skrive kode hvor du slipper å inngå kompromiss.
Lenke:
- Typescriptlang, 4.9 release notes: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-9.html