TypeScript lyver – og typesystemet ditt glemmer alt du har sjekket
Jeg har tenkt på Alexis Kings «Parse, don't validate» igjen. Det gjør jeg ganske ofte, egentlig – som regel etter å ha stirret på en TypeScript-kodebase som stille og rolig har samlet opp if (user.email)-sjekker overalt. Posten er fra 2019, og prinsippet er enda eldre enn det. Likevel validerer det meste av TypeScript jeg leser – inkludert, pinlig nok, en god del jeg har skrevet selv.
Kort sagt: en validator sier «dette ser greit ut, kjør på.» En parser sier «gi meg noe data, og jeg gir deg enten en presis type tilbake, eller forteller deg hva som er galt.» Forskjellen høres akademisk ut, helt til du innser at validatoren kaster informasjon i søpla i det øyeblikket den er ferdig, mens parseren fanger det den har lært og legger det inn i typen. Når du har parset en streng til en Email, trenger resten av programmet aldri å lure på det igjen.
I Elm eller F# er dette bare måten du skriver kode på. Språket dytter deg dit, enten du vil eller ikke. TypeScript? Not so much. TypeScript lar deg gjerne gjøre det riktige, men det insisterer ikke (for å si det mildt). Strukturell typing undergraver faktisk hele greia.
Validatoren vi alle har skrevet
Her er kode vi støter på hele tiden:
Ser du problemet? User.email er bare string. User.age er bare number. Valideringen skjedde – gratulerer med det! – men typesystemet glemte det i samme øyeblikk som isValidUser returnerte. Tre funksjonskall senere, når noen rører user.email, er det ingenting som hindrer dem i å sende den til en funksjon som forventer en faktisk epostadresse. For TypeScript er det bare en streng. Samme som "". Samme som "dette er definitivt ikke en epost".
Hva gjør vi? Vi validerer på nytt. Vi legger til enda en if. Vi skriver en enhetstest. Vi håper. King kaller dette «shotgun parsing»: validering strødd utover hele kodebasen, uten at noen av sjekkene huskes.
Det vi egentlig vil ha
Vi vil ha dette:
Og vi vil at det skal være umulig å kalle sendWelcome med noe som ikke har vært gjennom parseren. Ingen re-sjekking, fordi typen selv er beviset.
I Elm ville jeg brukt en opaque type og en smart konstruktør og vært ferdig omtrent før jeg begynte. I TypeScript er det ... mulig. Bare mindre elegant.
"Branded types": å lyve til typesystemet med vilje
TypeScript har strukturell typing. Det betyr at to typer med samme form er samme type. string er string er string. Det finnes ingen newtype som i Haskell, ingen måte å lage en genuint distinkt type av en primitiv.
Omveien som har blitt en slags standard er branding – du krysser typen med et «fantomfelt», dvs. noe som ikke eksisterer ved runtime:
Det finnes ikke noe brandfelt ved runtime. Det er en type-markør som gjør Email og string inkompatible ved compile time. Den eneste måten å få en Email på er gjennom en funksjon som vet hvordan det gjøres. Ingenting utenfor denne modulen kan navngi symbolet for å forfalske én, og det er hele poenget. Nominell inn i domenet, strukturell på vei ut. Akkurat det du vil ha.
Den funksjonen er parseren din kan da se omtrent sånn ut:
Den as Email-casten svir litt, og det skal den. Det er det ene stedet du har lov til å bryte reglene: parseren er den betrodde grensen. Overalt ellers i kodebasen kan du ikke trylle frem en Email fra en string. Du må kalle parseEmail og håndtere begge grenene.
Legg merke til at Result bruker navngitte varianter ("Ok" og "Err"), ikke et boolsk ok-flagg. Det er ikke tilfeldig. Mønsteret kommer fra språk som Elm (Ok value | Err error) og Rust, og det skalerer: den dagen du trenger en union med tre varianter, fungerer en switch på tag med uttømmende sjekking. En boolean gjør det ikke.
Sammenlign med "throw and pray"-validatoren vi startet med: den feiler med en exception, som er usynlig for typesystemet. Parserens signatur forteller deg alt som kan skje. Det finnes ikke noe tredje utfall som gjemmer seg i call stacken.
Nå til domenetypen. Jeg vil gi navn til to ting som vanligvis blandes sammen: den rå dataen som kom over nettverket, og den som har vunnet din tillit gjennom parseren (domenetypen).
parseUser tar unknown – den rå dataen som kom over nettverket – og gir tilbake en ValidUser som faktisk er til å stole på. Grensen mellom "rått" og "trygt" er en funksjon. UserId er også merket; hver primitiv i domenet ditt som ikke har en egen type er en utfordring du har utsatt, et problem du ikke har tatt stilling til. En UserId som ikke kan sendes der en OrderId forventes, er en av de billigste gevinstene i hele teknikken.
Altså: Gevinsten er at sendWelcome(user: ValidUser) nå er genuint trygg. Det finnes ingen vei gjennom kodebasen som produserer en ValidUser uten å gå gjennom parseUser. Typen er beviset, og valideringen ble ikke kastet i søpla.
Hva med verktøy?
Å skrive alt dette selv kan bli tungvint. Zod løser mye av det:
safeParse returnerer suksess eller feil (samme form som det vi bygde over, bare med andre feltnavn). .brand() er rent på typenivå, akkurat som symboltriks-versjonen. Du får parseren og typen fra én definisjon, som strukturelt tvinger frem den parseren/type-samlokaliseringen jeg ba deg opprettholde for hånd lenger opp.
Men det finnes noe som pusher idéen videre.
ArkType: parse og transformer i ett
ArkType gjør nemlig dette:
Strengene er typene. "string.email" er ikke en metodekjede. Det er en "strengliteral" (eller "bokstavelig streng") som ArkTypes kompilator parser til både en TypeScript-type og en runtime-validator. Og begrensningen på alder? Også en streng. "0 <= number.integer <= 150". Leses som en typeannotasjon du skulle ønske TypeScript hadde innebygd.
TypeScript-typen faller ut automatisk:
Ingen z.infer<typeof schema>. Du skriver tingen én gang, og begge sider – compile time og runtime – er enige om hva den betyr.
Branded types? Én linje:
"Morfer" er enda noen hakk kulere; de gjør «parse, don't validate» til «parse og transformer i én operasjon»:
"string.numeric.parse" tar inn en streng, validerer at den ser numerisk ut, og spytter ut et tall. Input-typen er { name: string; email: string; age: string }, output-typen er { name: string; email: string; age: number }. Én definisjon, to typer, en transformasjon imellom. Hele pipelinen er et enkelt uttrykk som typesystemet forstår fra ende til annen.
Og du kan kjede dem:
Rå JSON-streng inn, typet domeneobjekt ut. Der du plasserer parseren, der trekker du linjen mellom det du stoler på og det du ikke stoler på.
Ærlige avveininger
Streng-DSL-en er både det beste og det verste med ArkType. Den er konsis og lesbar og serialiserbar – du kan lagre skjemaer som vanlige strenger, noe Zods funksjonskjeder ikke kan. Men det er en DSL du må lære. TypeScript-feil inne i disse strengene ser annerledes ut enn vanlige TS-feil. IDE-en din vil ikke rename et felt inni "string.email".
Pakkestørrelsen er en annen ting. ArkType leverer rundt 42KB minifisert. Zod er ca. 13KB minifisert + gzippet. Valibot er under 9KB med tree-shaking. For en server er det revnende likegyldig. For en klientpakke der du teller kilobyte? Det er mye validator.
Økosystemet er ungt. Zod har 50+ integrasjoner (tRPC, Drizzle, React Hook Form, you name it). ArkType har kanskje fem.
Ytelse derimot? Absurd. ArkType benchmarker på ca. 14 nanosekunder for objektvalidering mot Zods 281. Tjue ganger raskere. For de fleste apper spiller det ærlig talt ingen rolle – validering er ikke flaskehalsen din. Men for hot paths eller høy-throughput APIer er det der om du trenger det.
Effect Schema: når parseren blir en effekt
Helt til slutt: Effect Schema, som er en del av Effect-TS – et helt FP-rammeverk for TypeScript. Jeg pleide å avskrive det med én gang (hvem orker en hel runtime bare for å parse litt JSON?), men det er én ting Effect Schema gjør som verken Zod eller ArkType kan måle seg med.
Effect har skjemaer går begge veier.
Ut av denne ene definisjonen får du to typer:
Den ene er domenetypen din, det du faktisk vil jobbe med inne i systemet. Den andre er "wire"-formatet (eller DTO, om du vil), det som kommer over nettverket eller skal serialiseres til en database. Schema.Date er en Date på innsiden og en ISO-streng på utsiden. NumberFromString er et tall i domenet og en streng over nettverket. id er en branded UserId hjemme og en helt vanlig streng hos JSON.
Og begge veier er garantert. Schema.decodeUnknown(User) tar deg fra rå data inn, Schema.encode(User) tar deg ut igjen. Samme skjema, ingen drift, ingen «oi, nå har vi to mappers og de ble nettopp uenige».
I Zod definerer du én type, den parsede. Skal du serialisere tilbake til wire-formatet (for cache, kø, logg, hva som helst) skriver du en annen funksjon. Eller – like ofte – lar du domenetypen din være en vag halvskygge av wire-formatet for å slippe konverteringen. Det er sånn vi havner med ISO-strenger i domenet, nullable-felt overalt, og string der vi egentlig vil ha Email. Domenet bøyer seg etter JSON. Men, Effect Schema holder de to typene atskilte og koblet, via skjemaet selv.
Feil er trær, ikke strenger:
Du får en ParseError med en strukturert ParseIssue-form: stien inn i dataen, hva som var forventet, hva som faktisk var der, hva som gikk galt. Du kan rendere det som JSON for et API, som feltmarkører i et skjema, som strukturert logg. Zods error.issues er en flat liste med stringly-typed paths. Det funker, men er en helt annen kvalitet på beviset.
Og fordi alt i Effect-økosystemet snakker samme språk, er en decode et Effect<A, ParseError, R>. Det R-et – kravet – kan være en database, en clock, en feature-flag-tjeneste. Du kan bygge skjemaer som faktisk trenger noe for å parse:
Typesignaturen sier rett ut at skjemaet krever en Clock. Verken Zod eller ArkType kan uttrykke det. Det er «parse, don't validate» tatt til siste stopp: parsing er ikke en boolsk pinpunkt-sjekk lenger, det er en effektful beregning som skjemaet selv er ærlig om.
Ærlige avveininger vol 2 (da capo, men verre)
Effect er ikke et validator-bibliotek, det er en runtime. Hele effect-pakken er rundt 290KB min+gzipped. Med tree-shaking og bare Schema-biten lander du nok på 30-60KB, men du er fortsatt et godt stykke fra Zods 13KB og Valibots 9KB. Buy in gjelder et FP-rammeverk, ikke bare en parser.
Læringskurven er vertikal. Effect, Either, Layer, Service, Fiber, Scope – og det er bare hvis du vil bruke skjemaet i sin egentlige kontekst. Skal du bare parse JSON, klarer du deg med decodeUnknownEither og kan late som resten ikke finnes. Men da kjøper du veldig mye runtime du aldri rører.
Økosystemet er smalere. Zod har sine 50+ integrasjoner. Effect har sin egen stack – @effect/platform, @effect/sql, @effect/rpc. Fint hvis du går all in. Slitsomt hvis du vil pakke det rundt eksisterende verktøy.
Ytelsen? Fin, men ikke best. I nærheten av Zod, betydelig saktere enn ArkType eller Typia. Hvis du teller nanosekunder, er det ikke dette du velger.
Men. Effect Schema er det eneste biblioteket der skjemaet ditt kan si «for å dekode dette trenger jeg en database, det kan feile på disse måtene, og her er reverse-funksjonen, gratis». Skriver du Effect allerede, eller leker med tanken – så er Schema det åpenbare valget. Er du ikke der enda er det fortsatt verdt å se på, om ikke annet som en demonstrasjon av hvor langt «parse, don't validate» faktisk strekker seg når noen tar idéen på alvor.
Poenget
Hver gang du sjekker noe uten å fange resultatet i en type, overlater du til en fremtidig versjon av deg selv (eller en kollega) å huske at validering skjedde og hvor det skjedde, og i hvilke tilfeller man eventuelt må sjekke på nytt. Det er en ganske dårlig deal. Men hvis du følger rådet «parse, don't validate», åpner det nye muligheter!
I TypeScript betyr det å lene seg på tre ting språket faktisk gir deg, om enn motvillig: branded types for nominell identitet, merkede unioner for ærlig feilhåndtering, og en streng grense mellom unknown (det som kom utenfra) og domenetypene dine (det du har gjort deg fortjent til å stole på). Ingenting av dette er like rent som Elm, forresten – jeg bare nevner det. Men alt er bedre enn alternativet.
Og her er greia: å parse data er bare halve problemet. Funksjonene dine lyver også – om hva de trenger, og hvordan de kan feile. Det tar vi i del 2.



