Hvorfor domene-modellering med typer?
De fleste applikasjoner feiler ikke fordi algoritmer er vanskelige, men fordi modeller tillater tilstander som ikke gir mening i domenet. «Bruker uten e‑post, men som er verifisert», «ordre som er både sendt og annulert», «sum < 0», «modal dialog er både lukket og aktiv». Slike tilstander burde ikke være mulig i utgangspunktet.
Among the most time-consuming bugs to track down are the ones where we look at our application state and say "this shouldn’t be possible."
- Richard Feldman, elm-conf 2016
Typede funksjonelle språk (Elm, Haskell, F# m.fl.) skinner her, fordi de gir oss verktøy som lar oss uttrykke domenets regler i selve typesystemet. Resultatet er at kompilatoren nekter å bygge programmet når vi forsøker å representere en ulovlig tilstand. Kort sagt: gjør umulige tilstander umulige.
Hvis "no runtime exceptions" høres fristende ut, så må jo "no impossible state at runtime" være enda bedre! 🥳
Rike typer er selvdokumenterende
En viktig observasjon fra F#‑boken «Domain Modeling Made Functional» er at gode domenetyper bør være så klare at også domeneeksperter (uten programmeringsbakgrunn) kan lese dem og kjenne igjen begreper og regler. Med andre ord: velvalgte, rike typer fungerer som levende dokumentasjon, og kompilatoren håndhever dem. Se boken her.
Dette er samtidig et av de sterkeste argumentene for typede funksjonelle språk: de gjør det naturlig å uttrykke domenet presist, få rask tilbakemelding i kompilering, og samarbeide tettere med fagfolk om de riktige begrepene. I Enso bygger vi nettopp slike modeller i prosjekter hvor robusthet og endringsvennlighet er kritisk.
Parse, don’t validate
I stedet for å «validere» data spredt rundt i koden, gjør vi én ting før vi tar inn data i domenelaget: vi parser rådata inn til rike, typesikre domeneverdier. Derfra jobber resten av systemet med verdier som allerede er garantert gyldige. Dette prinsippet er utmerket forklart i «Parse, don’t validate» [Lexi Lambda, 2019] (lenke).
Eksempel i Elm – en ikke-tom streng og en e‑post:
module Domain exposing (NonEmptyString, Email, nonEmpty, email)
type NonEmptyString
= NonEmptyString String -- legg merke til at denne "constructoren" ikke er exposed, så denne typen kan kun genereres med funksjoner i samme modul
nonEmpty : String -> Result String NonEmptyString
nonEmpty s =
if String.length s > 0 then
Ok (NonEmptyString s)
else
Err "Kan ikke være tom"
type Email
= Email String
email : String -> Result String Email
email s =
if String.contains "@" s then
Ok (Email s)
else
Err "Ugyldig e‑post"
Poenget: Etter parsing finnes det ingen «tom streng» eller «ugyldig e‑post» i domenet. De kan kun eksistere som feil i grensekoden, ikke i resten av systemet. Og siden verdiene er immutable, kan de heller ikke bli «ødelagt» ved uhell senere i programflyten.
Nyttige teknikker selv i andre språk
Selv i ikke-funksjonelle språk kan vi dra nytte av noen av disse prinsippene. Private constructors i språk som Kotlin kan hjelpe oss med å lage sikrere domenemodeller:
class NonEmptyString private constructor(val value: String) {
companion object {
fun create(s: String): NonEmptyString? {
return if (s.isNotEmpty()) NonEmptyString(s) else null
}
}
}
Dette gir oss noe av den samme sikkerheten, men vi mangler fortsatt mange elementer vi får gratis i funksjonelle språk. Sterkt typede funksjonelle språk som Elm, F# og Haskell er overlegne på dette området – ikke bare med rike typer, sumtyper og phantom types, men også fordi verdier er immutable by default. Dette gir oss dobbel trygghet: både at ulovlige tilstander ikke kan opprettes, og at gyldige tilstander ikke kan ødelegges ved uhell senere.
Sumtyper: Én kilde til sannhet for state
I stedet for spredte booleans, representer mulig tilstand med en eksplisitt union/sumtype. Dette er kjernen i «Making impossible states impossible» (video).
type Session
= Anonymous
| Authenticated User
-- Umulig å ha en «delvis innlogget» bruker.
Et annet eksempel er asynkron lasting (unngå
isLoading
,
error
,
data
som kan motsi hverandre):
type RemoteData error value
= NotAsked
| Loading
| Success value
| Failure error
Her er hver tilstand gjensidig utelukkende og fullstendig – UI‑logikk blir både enklere og tryggere.
Phantom types: Utsiling i kompileringstid
Phantom types lar oss «fargelegge» verdier uten runtime‑kostnad. De brukes til å single ut elementer som ikke skal kunne blandes. Jeroen Engels viser et elegant Elm‑eksempel der «grønne biler» og «forurensende biler» skilles ved hjelp av en typeparameter (artikkel).
type Car fuel
= ElectricCar
| HydrogenCar
| DieselCar
type Green = Green
type Polluting = Polluting
electricCar : Car fuel
electricCar = ElectricCar
hydrogenCar : Car fuel
hydrogenCar = HydrogenCar
dieselCar : Car Polluting
dieselCar = DieselCar
createGreenCarFactory : (data -> List (Car Green)) -> Factory
createGreenCarFactory build =
-- implementasjon uvesentlig; signaturen forbyr diesel
Debug.todo "..."
Nøkkelen er at electricCar
og hydrogenCar
er polymorfe (Car fuel
) og kan derfor oppføre seg som «grønne» når det kreves, mens dieselCar
er låst til Polluting
og nektes av kompilatoren der «grønt» forventes.
Prosessflyt som state machine med phantom types
Phantom types egner seg også til å modellere prosessflyt hvor rekkefølgen må være riktig, uten å lage et mylder av mellomtyper. Dette speiler mønsteret beskrevet i Elm Patterns (kilde).
-- En «Step» bærer med seg hvilken fase vi er i, som en phantom type
type Step step
= Step Order
type Start = Start
type WithTotal = WithTotal
type WithQuantity = WithQuantity
type Done = Done
start : Order -> Step Start
setTotal : Int -> Step Start -> Step WithTotal
adjustQuantityFromTotal : Step WithTotal -> Step Done
setQuantity : Int -> Step Start -> Step WithQuantity
adjustTotalFromQuantity : Step WithQuantity -> Step Done
done : Step Done -> Order
-- To lovlige flyter
flowPrioritizingTotal : Int -> Order -> Order
flowPrioritizingTotal total order =
start order
|> setTotal total
|> adjustQuantityFromTotal
|> done
flowPrioritizingQuantity : Int -> Order -> Order
flowPrioritizingQuantity quantity order =
start order
|> setQuantity quantity
|> adjustTotalFromQuantity
|> done
Signaturene forhindrer at vi hopper over steg eller blander rekkefølgen. Dette gir fordelen fra en tilstandsmaskin – med kompilatorsjekk – uten eksplosjon av egne mellomtyper.
Phantom Builder Pattern: Riktig rekkefølge, garantert
Byggere kan også typesikres slik at nødvendige steg må tas i riktig rekkefølge – før et «ferdig» objekt kan produseres. Se «Phantom Builder Pattern» (video).
La oss bruke en extensible record (row types) i Elm for å bygge en Button
. Krav: knappen må ha interaktivitet og tekst/ikon før den kan rendres.
module Button exposing (Button, new, withDisabled, withOnClick, withText, withIcon, toHtml)
-- Phantom-parameteren `constraints` finnes bare i type-signaturene (ikke i constructor)
type Button constraints msg
= Button (List (Html.Attribute msg)) (List (Html msg))
-- Starttilstand: vi MÅ velge en interaksjon (onClick ELLER disabled)
new : Button { needsInteractivity : () } msg
new =
Button [] []
withDisabled :
Button { c | needsInteractivity : () } msg
-> Button { c | hasInteractivity : () } msg
withDisabled (Button attrs children) =
Button (Html.Attributes.disabled True :: attrs) children
withOnClick :
msg
-> Button { c | needsInteractivity : () } msg
-> Button { c | hasInteractivity : () } msg
withOnClick message (Button attrs children) =
Button (Html.Events.onClick message :: attrs) children
withText :
String
-> Button c msg
-> Button { c | hasTextOrIcon : () } msg
withText str (Button attrs children) =
Button attrs (Html.text str :: children)
withIcon :
Html msg
-> Button c msg
-> Button { c | hasTextOrIcon : () } msg
withIcon icon (Button attrs children) =
Button attrs (icon :: children)
toHtml :
Button { c | hasInteractivity : (), hasTextOrIcon : () } msg
-> Html msg
toHtml (Button attrs children) =
Html.button (List.reverse attrs) (List.reverse children)
Signaturene gjør jobben: toHtml
kan ikke kalles før vi har oppfylt begge kravene. Vi kan velge rekkefølge fritt, og vi kan legge til flere «merkinger» senere uten å endre eksisterende brukere.
Eksempel på bruk i en view
‑funksjon:
view : Model -> Html Msg
view model =
div []
[ Button.new
|> Button.withOnClick SaveClicked
|> Button.withText "Lagre"
|> Button.toHtml
, Button.new
|> Button.withDisabled
|> Button.withIcon (Icons.lock [])
|> Button.toHtml
, Button.new
|> Button.withOnClick Cancel
|> Button.withText "Avbryt"
|> Button.toHtml
-- ❌ Dette bygger ikke: mangler `hasTextOrIcon`
, Button.new
|> Button.withOnClick Cancel
|> Button.toHtml
-- ❌ Dette bygger ikke: mangler `hasInteractivity`
, Button.new
|> Button.withText "Ubrukelig knapp"
|> Button.toHtml
]
Forsøker vi å kalle toHtml
uten først å ha lagt til interaktivitet OG tekst/ikon, vil kompilatoren feile – akkurat som ønsket.
Praktisk sjekkliste for «umulige tilstander»
- Definer sumtyper for tilstander som ellers ville vært booleans som kan kombineres feil
- Lag rike domeneverdier (newtypes/alias) for «viktige strenger» som E‑post, UUID, NonEmpty, NonNegative osv
- Bruk "smart constructors" internt og ikke eksponér typegenerator
- Parse ved grensene (IO/HTTP/DB) – gi resten av systemet trygge typer
- Bruk phantom types for å skille undergrupper som ikke skal blandes (f. eks. verifisert epost vs ikke verifisert)
- Tenk byggesekvenser som typer (phantom builder) når rekkefølge er viktig
Testing og kompilatorhåndtering
Med sterke typer og rike domenemodeller tar kompilatoren mye av byrden ved å sikre at ulovlige tilstander ikke kan eksistere. Dette reduserer behovet for manuelle tester, siden mange feil allerede blir fanget opp i kompileringstid.
Selvfølgelig er testing fortsatt viktig for å sikre at logikken oppfører seg som forventet, men kompilatoren hjelper oss med å eliminere en stor klasse av feil som ellers ville vært vanskelige å finne og fikse. I stedet for å teste at «bruker ikke kan være både innlogget og utlogget», garanterer typesystemet at denne tilstanden rett og slett ikke kan eksistere.
Når er dette verdt det?
Dette er spesielt verdifullt i systemer hvor robusthet, forutsigbarhet og vedlikeholdbarhet er viktig. Når konsekvensene av feiltilstander er store – enten for brukere, virksomhet eller sikkerhet – lønner det seg å modellere domenet slik at ulovlige tilstander blir umulige allerede i kompileringstid.
Eller, som man sier om å skrive tester: bare gjør det der du ikke vil applikasjonen skal feile...
Konklusjon
Typede funksjonelle språk gjør det både mulig og naturlig å flytte validering fra runtime til kompileringstid. Med sumtyper, rike domeneverdier og phantom types kan vi oppnå modeller som rett og slett ikke lar oss representere ulovlige tilstander. Det gir enklere kode, tryggere refaktorering og færre produksjonsfeil.
Med økende kompleksitet i moderne systemer og et stadig større behov for robusthet i kritisk infrastruktur, blir teknikker for å gjøre umulige tilstander umulige enda mer relevante. Sterkt typede funksjonelle språk gir oss verktøyene vi trenger for å bygge systemer som er både pålitelige og enkle å vedlikeholde – noe som er avgjørende for å møte fremtidens utfordringer.
Referanser
- Parse, don’t validate – Lexi Lambda: https://lexi-lambda.github.io/blog/2019/11/05/parse-don-t-validate/
- Single out elements using phantom types – Jeroen Engels: https://jfmengels.net/single-out-elements-using-phantom-types
- Making impossible states impossible (video): https://www.youtube.com/watch?v=IcgmSRJHu_8
- Phantom Builder Pattern (video): https://www.youtube.com/watch?v=Trp3tmpMb-o&t=377s
- Domain Modeling Made Functional – Scott Wlaschin: https://amzn.to/4loKAlq
- Elm Patterns – Process flow using phantom types: https://sporto.github.io/elm-patterns/advanced/flow-phantom-types.html