Jeg var ute på min vanlige joggetur da jeg plutselig stoppet opp. Der, i et vindu, så jeg noe av det vakreste jeg hadde sett på lenge: LEGO-blomster. Fargerike og evigvarende. Som blomsterentusiast føltes det som å oppdage en perfekt kombinasjon av to verdener.

Det er fascinerende hvor tidløst populært LEGO er. En av styrkene ved LEGO er hvor enkelt det er å identifisere hver brikke, selv i en stor samling. Bare ved et raskt blikk kan du skille en 2x4-kloss fra en blomsterstengel eller et hjul. Dette prinsippet - å kunne identifisere typen basert på tydelige kjennetegn - er akkurat det som gjør Discriminated Unions så kraftfulle i programmering.

På samme måte som du umiddelbart gjenkjenner en LEGO-brikke basert på dens karakteristikker, kan TypeScript identifisere objekttyper gjennom en diskriminator-egenskap, og Zod kan validere at data følger disse mønstrene under kjøretid. I denne bloggposten skal vi se på et praktisk eksempel på hvordan du kan bruke disse verktøyene for å bygge robuste og typesikre systemer.

Snikbilde fra joggetur av lego-blomster
Snikbilde av noen sine LEGO-blomster.

Hva er Discriminated Unions?

Discriminated Unions lar oss skape en type med flere varianter, men med én felles egenskap. Dette er den såkalte “diskriminatoren” som brukes for å bestemme hvilken spesifikk type vi jobber med. Under lager vi en type for LEGO-brikker hvor legoType er diskriminator-egenskapen.

type LEGOBrikke = 
  | { legoType: 'kloss'; antallKnotter: number, farge: string }
  | { legoType: 'blomsterblad'; farge: string; bredde: number }
  | { legoType: 'stengel'; farge: string; lengde: number }
  | { legoType: 'hjul'; diameter: number; farge: string };
LEGO sortert etter type
LEGO sortert etter type/funksjon for enklere bygging.

Spillereglene: TypeScript vs. Zod

Discriminated Unions er et kraftig mønster som støttes av både TypeScript og Zod, men på litt forskjellige måter og med ulike formål. For å forstå hvordan vi kan utnytte dette mønsteret best mulig, må vi først forstå hvilken rolle hver av disse teknologiene spiller:

TypeScript

  • Statisk typekontroll under kompilering
  • Typene eksisterer kun under utvikling, ikke i produksjonskoden
  • Gir IDE-støtte som autofullføring og feilmarkering
  • Fanger opp typefeil tidlig i utviklingsprosessen

Zod

  • Validerer data under kjøretid
  • Særlig nyttig for å validere ekstern data (API-responser, brukerinput)
  • Kaster feil hvis data ikke følger det definerte skjemaet
  • Integreres sømløst med populære biblioteker som React Hook Form og TanStack Form

Hvordan de jobber sammen: Du definerer datastrukturen én gang i Zod, og TypeScript kan utlede (infer) typene fra Zod-skjemaene. Dette samarbeidet eliminerer duplisering og sikrer at typene og valideringen er synkronisert. I eksemplene som følger skal vi se hvordan dette fungerer i praksis.

Discriminated Unions i praksis: Et LEGO-samlingssystem

Heldigvis fant jeg lego-blomstene på nett, så det er fare for at jeg nå blir en del av AFOL – også kjent som “Adult fans of LEGO”. Som ny LEGO-entusiast trenger jeg selvfølgelig et system for å holde orden på samlingen min. Dette gir meg den perfekte anledningen til å demonstrere Discriminated Unions med TypeScript og Zod.

Produktbilde av LEGO-blomster LEGO-blomster i produktboksen

Steg 1: Velge diskriminator

Det første og viktigste valget er diskriminatoren - den egenskapen som gjør at vi kan skille mellom ulike typer. I vårt LEGO-samlingssystem ønsker jeg å skille på sett-typer siden det tydelig forteller hvilken kategori av LEGO vi jobber med.

import { z } from "zod";

// Definere LEGO-sett typer med enum for bruk som diskriminator
const legoSetTypeSchema = z.enum([
  "PLANTS",
  "VEHICLES",
  "BUILDINGS"
]);

// Type-inferens for LEGO-sett typer
export type LegoSetType = z.infer<typeof legoSetTypeSchema>;
export const LegoSetEnum = legoSetTypeSchema.enum;

Vi bruker en enum her fordi den gir oss et avgrenset sett med forhåndsdefinerte verdier. Dette er perfekt for en diskriminator siden det gir oss uttømmende type-sjekking og forhindrer ugyldige verdier.

Steg 2: Definere basis for LEGO-sett

Nå definerer vi de grunnleggende egenskapene som alle LEGO-sett deler, uavhengig av type:

const baseLegoSetSchema = z.object({
  setNumber: z.string(),
  name: z.string(),
  pieceCount: z.number().positive(),
  ageGroup: z.string(),
  price: z.number().positive(),
  buildInstructions: z.boolean().default(true),
});

export type BaseLegoSetType = z.infer<typeof baseLegoSetSchema>;

Steg 3: Definere spesifikke kategoriskjemaer

Vi bygger på basisskjemaet og definerer hver kategori i mer detalj. Som du ser under kan hver kategori ha veldig forskjellige egenskaper. Legg merke til hvordan vi bruker LegoSetEnum som ble definert i steg 1.

// Plantesett
const plantSetSchema = baseLegoSetSchema.extend({
  type: z.literal(LegoSetEnum.PLANTS),
  details: z.object({
    plantType: z.enum(["rose", "sunflower", "orchid", "cactus"]),
    height: z.number().positive(),
    vaseIncluded: z.boolean().default(true),
  }),
});

// Kjøretøysett
const vehicleSetSchema = baseLegoSetSchema.extend({
  type: z.literal(LegoSetEnum.VEHICLES),
  details: z.object({
    vehicleType: z.enum(["car", "boat", "plane", "train"]),
    brand: z.string().optional(),
    model: z.string().optional(),
  }),
});

// Bygningssett
const buildingSetSchema = baseLegoSetSchema.extend({
  type: z.literal(LegoSetEnum.BUILDINGS),
  details: z.object({
    buildingType: z.enum(["residential", "historical", "fantasy"]),
    floors: z.number().positive(),
    furnished: z.boolean(),
  }),
});

Steg 4: Kombinere til en Discriminated Union

Her bruker vi type som diskriminator for å kombinere alle skjemaene:

// LEGO-sett union
const legoSetSchema = z.discriminatedUnion("type", [
  plantSetSchema,
  vehicleSetSchema,
  buildingSetSchema,
]);

// Type-inferens for unionen fra skjemaet
type LegoSet = z.infer<typeof legoSetSchema>;

Steg 5: Skape og bruke typesikker hjelpefunksjon

Nå som vi har definert strukturen, trenger vi en enkel måte å opprette nye LEGO-sett på. Uten en hjelpefunksjon måtte vi manuelt skrevet ut alle detaljene for hvert sett, noe som fort blir omstendelig og feilbetont:

// Manuell oppretting uten hjelpefunksjon 
const roseSetManual: LegoSet = {
  type: LegoSetEnum.PLANTS, // Må huske å sette riktig type
  setNumber: "10280",
  name: "Flower Bouquet",
  pieceCount: 756,
  ageGroup: "18+",
  price: 499,
  details: {
    plantType: "rose",
    height: 25,
    vaseIncluded: true,
  }
};

Dette virker enkelt nok for ett eksempel, men med flere titalls eller hundretalls sett blir det fort tungt å vedlikeholde. Dessuten er det lett å gjøre feil når man manuelt må sørge for at type og details samsvarer.

En bedre løsning er å lage en hjelpefunksjon:

// Hjelpefunksjon for å lage LEGO-sett objekter
export function createLegoSet<T extends LegoSet>(
  setType: LegoSetType, 
  details: T["details"],
  baseInfo: Omit<BaseLegoSetType, "type" | "details">
): LegoSet {
  return {
    ...baseInfo,
    type: setType,
    details,
  } as LegoSet;
}

Denne funksjonen gir flere fordeler:

  • Den sikrer at type og details alltid samsvarer
  • TypeScript kan gi bedre type-sjekking når vi oppretter nye sett
  • Vi kan fokusere på å oppgi data snarere enn å bekymre oss for strukturen
  • Det blir enklere å endre strukturen senere hvis nødvendig

Med denne hjelpefunksjonen på plass kan vi nå enkelt opprette ulike typer LEGO-sett mens TypeScript sikrer at vi oppgir korrekte detaljer for hver type:

// Opprette et plantesett
const roseSet = createLegoSet(
  LegoSetEnum.PLANTS,
  {
    plantType: "rose",
    height: 25,
    vaseIncluded: true,
  },
  {
    setNumber: "10280",
    name: "Flower Bouquet",
    pieceCount: 756,
    ageGroup: "18+",
    price: 499,
  }
);

// Opprette et kjøretøysett
const carSet = createLegoSet(
  LegoSetEnum.VEHICLES,
  {
    vehicleType: "car",
    brand: "Porsche",
    model: "911",
  },
  {
    setNumber: "10295",
    name: "Porsche 911",
    pieceCount: 1458,
    ageGroup: "18+",
    price: 1499,
  }
);

IDE-en vil nå hjelpe deg med å velge riktige egenskaper for hver type. Hvis du prøver å gi plantedetaljer til et kjøretøy, vil TypeScript gi en feilmelding før koden kompileres. Deilig, ikke sant?

LEGO-figurer som går over et fortau som Beatles
Photo by Daniel K Cheung on Unsplash

Type innsnevring i handling (hvordan TypeScript bruker diskriminatoren)

Når vi bruker union-typen i en funksjon, kan TypeScript automatisk snevre inn de tilgjengelige egenskapene basert på verdien av diskriminatoren. Under ser du at IDE-en forstår konteksten i hver gren av switch-uttrykket og gir deg presise forslag. I BUILDINGS-casen vet IDE-en at buildingType og floors er tilgjengelige, men ikke egenskaper fra andre typer som vehicleType eller height.

IDE viser kun tilgjengelige egenskaper basert på type
Implementasjon av funksjonen beskrivLEGOSett for å vise IDE-støtte

Validering av ekstern data (hvordan Zod validerer under kjøretid)

Med skjemaet på plass kan vi nå validere data når programmet kjører:

// Eksempel på data som kan komme fra en API
const inputData = {
  type: "PLANTS",
  setNumber: "10281",
  name: "Bonsai Tree",
  pieceCount: 878,
  ageGroup: "18+",
  price: 599,
  details: {
    plantType: "bonsai", // Dette vil feile validering!
    height: 20,
    vaseIncluded: true
  }
};

// Validering med Zod
try {
  const validatedSet = legoSetSchema.parse(inputData);
  beskrivLEGOSett(validatedSet);
} catch (error) {
  console.error("Invalid LEGO set:", error);
}

Error fra validering:

ZodError: [
     { "code": "invalid_enum_value", 
       "path": ["details", "plantType"], 
       "message": "Invalid enum value. Expected 'rose' | 'sunflower' | 'orchid' | 'cactus'" 
     }
   ]

Zod 4 går fra beta til stabil 19. mai 2025

Zod 4 blir stabil senere i mai etter en lengre betaperiode. En av forbedringene er at Zod nå automatisk identifiserer diskriminatoren i Discriminated Unions. Dette gjør koden ryddigere og mer intuitiv. Hvis ingen felles diskriminatornøkkel finnes, kaster Zod en feil allerede ved skjemainitialisering.

// I Zod 4 trenger du ikke spesifisere diskriminatornøkkelen
const legoSetSchema = z.discriminatedUnion([
  plantSetSchema, 
  vehicleSetSchema,
  buildingSetSchema
]);

Andre nyttige forbedringer inkluderer:

  • Sammensettbare unioner: Du kan nå bruke en discriminated union som medlem av en annen union
  • Betydelige ytelsesforbedriger: Validering er 3-7x raskere, TypeScript-kompilering er dramatisk forbedret, og pakkestørrelsen er redusert med 50%

Fem grunner til å digge Discriminated Unions med TypeScript og Zod

  1. Én definisjon, to fordeler: Definér Zod-skjemaet én gang, få både validering og TypeScript-typer.

  2. Typesikkerhet: TypeScript kan snevre inn typen basert på diskriminator-egenskapen, som gir full IDE-støtte.

  3. Validering i kjøretid: Zod sikrer at dataene dine faktisk har riktig struktur når programmet kjører, ikke bare under utvikling.

  4. Uttømmelseskontroll: Kompilatoren sørger for at du har håndtert alle mulige tilfeller i switch-uttrykk og lignende.

  5. Ryddig kodeorganisering: Hver variant av unionen har sin egen struktur og valideringsregler.

Det var alt jeg hadde å si for denne gangen, nå skal jeg nyte synet av mine nye LEGO-blomster. Hva er din erfaring rundt Discriminated Unions? Jeg hører gjerne tilbakemelding eller refleksjoner!

Marinas stue med LEGO-blomster
Marinas stue med LEGO-blomster