惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
S
Secure Thoughts
C
Cyber Attacks, Cyber Crime and Cyber Security
SecWiki News
SecWiki News
AWS News Blog
AWS News Blog
P
Proofpoint News Feed
H
Help Net Security
I
InfoQ
T
Tor Project blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
Schneier on Security
Schneier on Security
The GitHub Blog
The GitHub Blog
S
Schneier on Security
The Hacker News
The Hacker News
博客园 - Franky
雷峰网
雷峰网
S
Security @ Cisco Blogs
Google Online Security Blog
Google Online Security Blog
Engineering at Meta
Engineering at Meta
G
GRAHAM CLULEY
Security Latest
Security Latest
The Cloudflare Blog
M
MIT News - Artificial intelligence
云风的 BLOG
云风的 BLOG
K
Kaspersky official blog
C
Cisco Blogs
Microsoft Security Blog
Microsoft Security Blog
宝玉的分享
宝玉的分享
Scott Helme
Scott Helme
腾讯CDC
阮一峰的网络日志
阮一峰的网络日志
A
Arctic Wolf
Latest news
Latest news
Last Week in AI
Last Week in AI
The Register - Security
The Register - Security
Google DeepMind News
Google DeepMind News
博客园 - 聂微东
T
Threatpost
G
Google Developers Blog
有赞技术团队
有赞技术团队
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
L
LINUX DO - 热门话题
aimingoo的专栏
aimingoo的专栏
S
Securelist
WordPress大学
WordPress大学
N
Netflix TechBlog - Medium
Y
Y Combinator Blog
T
The Exploit Database - CXSecurity.com
月光博客
月光博客
Recent Announcements
Recent Announcements

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Making illegal state unrepresentable
Nicolas Frän · 2026-04-23 · via DEV Community

A couple of years ago, I wrote that The Builder pattern is a finite state machine!. A state machine consists of states and transitions between them. As a developer, I want to make illegal states unrepresentable, i.e., users of my API can't create non-existent transitions. My hypothesis is that only a static typing system allows this at compile-time. Dynamic typing systems rely on runtime validation.

In this blog post, I will show that it holds true, with a caveat. If your model has many combinations, you also need generics and other niceties to avoid too much boilerplate code. My exploration will use Python, Java, Kotlin, Rust, and Gleam. With that background in mind, let's move on to the model itself.

The model

I will keep the theme of the aforementioned post: a pizza. Here's the full model:

States-transitions diagram for a simple pizza builder

Every pizza starts with dough. Then, you need to choose your base, either regular tomato or cream. At this point, you can't add any ingredients to it. While you can add ham to both, pineapple is only acceptable on the tomato base, while potatoes are only compatible with the cream base.

The model is purposely simplistic, intentionally leaving out some options and complexities to maintain clarity. It primarily showcases how illegal transitions are represented and avoided. For example, you can't transition from CreamBase to CreamBase with withPineapple.

Modeling with Python

I chose to model with Python first to demonstrate how dynamically-typed languages manage illegal transitions.

Here are some types:

class Crust:
    pass

@dataclass(frozen=True)
class ThinCrust(Crust):
    pass

@dataclass(frozen=True)
class ThickCrust(Crust):
    pass

class Base:
    pass

@dataclass(frozen=True)
class CreamBase(Base):
    pass

@dataclass(frozen=True)
class TomatoBase(Base):
    pass

class Topping:
    pass

@dataclass(frozen=True)
class Potatoes(Topping):
    pass

@dataclass(frozen=True)
class Pineapple(Topping):
    pass

Enter fullscreen mode Exit fullscreen mode

The builders look like:

class DoughBuilder:
    def __init__(self, crust: Crust) -> None:
        self._crust = crust

    def with_cream_base(self) -> "CreamBuilder":
        return CreamBuilder(self._crust, CreamBase(), frozenset())

    def with_tomato_base(self) -> "TomatoBuilder":
        return TomatoBuilder(self._crust, TomatoBase(), frozenset())


class CreamBuilder:
    def __init__(self, crust: Crust, base: CreamBase, toppings: frozenset[Topping]) -> None:
        self._crust = crust
        self._base = base
        self._toppings = toppings

    def with_potatoes(self) -> "CreamBuilder":
        return CreamBuilder(self._crust, self._base, self._toppings | {Potatoes()})

    def with_ham(self) -> "CreamBuilder":
        return CreamBuilder(self._crust, self._base, self._toppings | {Ham()})

    def with_olives(self) -> "CreamBuilder":
        return CreamBuilder(self._crust, self._base, self._toppings | {Olives()})

    def build(self) -> Pizza:
        return Pizza(self._crust, self._base, self._toppings)


class TomatoBuilder:
    def __init__(self, crust: Crust, base: TomatoBase, toppings: frozenset[Topping]) -> None:
        self._crust = crust
        self._base = base
        self._toppings = toppings

    def with_pineapple(self) -> "TomatoBuilder":
        return TomatoBuilder(self._crust, self._base, self._toppings | {Pineapple()})

    def with_ham(self) -> "TomatoBuilder":
        return TomatoBuilder(self._crust, self._base, self._toppings | {Ham()})

    def with_olives(self) -> "TomatoBuilder":
        return TomatoBuilder(self._crust, self._base, self._toppings | {Olives()})

    def build(self) -> Pizza:
        return Pizza(self._crust, self._base, self._toppings)

Enter fullscreen mode Exit fullscreen mode

You can happily write:

pizza = dough(ThinCrust()).with_cream_base().with_potatoes().with_ham().build()
pizza = dough(ThinCrust()).with_cream_base().with_pineapple().with_ham().build()   # 1

Enter fullscreen mode Exit fullscreen mode

  1. Pineapple on a creamy base? Yuck!

Astute readers may have noticed that I used types in the Python code. Seasoned Python developers may use a type checker, e.g., mypy, to leverage them.

uv run mypy .

Enter fullscreen mode Exit fullscreen mode

"CreamBuilder" has no attribute "with_pineapple"  [attr-defined]

Enter fullscreen mode Exit fullscreen mode

Without mypy, the call fails at runtime with an AttributeError.

Python's gradual typing proves that static typing helps avoid calling non-existent transitions. However, because of the way Python works, one needs to invoke a dedicated type checker. In statically-typed languages, it's unnecessary.

Modeling with Java

Let's do the same exercise with Java.

Here's the model:

public sealed interface Crust permits ThinCrust, ThickCrust {}
public record ThinCrust() implements Crust {}
public record ThickCrust() implements Crust {}

sealed interface BaseState permits CreamBase, TomatoBase {}
record CreamBase() implements BaseState {}
record TomatoBase() implements BaseState {}

Enter fullscreen mode Exit fullscreen mode

And here are the builders:

public class DoughBuilder {

    private final Crust crust;

    private DoughBuilder(Crust crust) {
        this.crust = crust;
    }

    public static DoughBuilder dough(Crust crust) {
        return new DoughBuilder(crust);
    }

    CreamBaseBuilder withCreamBase() {
        return new CreamBaseBuilder(crust, new CreamBase());
    }

    TomatoBaseBuilder withTomatoBase() {
        return new TomatoBaseBuilder(crust, new TomatoBase());
    }
}

class CreamBaseBuilder {

    private final Crust crust;
    private final CreamBase base;
    private final List<Topping> toppings;

    CreamBaseBuilder(Crust crust, CreamBase base) {
        this(crust, base, List.of());
    }

    private CreamBaseBuilder(Crust crust, CreamBase base, List<Topping> toppings) {
        this.crust = crust;
        this.base = base;
        this.toppings = toppings;
    }

    CreamBaseBuilder withPotatoes() {
        return new CreamBaseBuilder(crust, base, append(new Potatoes()));
    }

    CreamBaseBuilder withHam() {
        return new CreamBaseBuilder(crust, base, append(new Ham()));
    }

    CreamBaseBuilder withOlives() {
        return new CreamBaseBuilder(crust, base, append(new Olives()));
    }

    Pizza build() {
        return new Pizza(crust, base, Set.copyOf(toppings));
    }

    private List<Topping> append(Topping topping) {
        return Stream.concat(toppings.stream(), Stream.of(topping)).toList();
    }
}

class TomatoBaseBuilder {

    private final Crust crust;
    private final TomatoBase base;
    private final List<Topping> toppings;

    TomatoBaseBuilder(Crust crust, TomatoBase base) {
        this(crust, base, List.of());
    }

    private TomatoBaseBuilder(Crust crust, TomatoBase base, List<Topping> toppings) {
        this.crust = crust;
        this.base = base;
        this.toppings = toppings;
    }

    TomatoBaseBuilder withPineapple() {
        return new TomatoBaseBuilder(crust, base, append(new Pineapple()));
    }

    TomatoBaseBuilder withHam() {
        return new TomatoBaseBuilder(crust, base, append(new Ham()));
    }

    TomatoBaseBuilder withOlives() {
        return new TomatoBaseBuilder(crust, base, append(new Olives()));
    }

    Pizza build() {
        return new Pizza(crust, base, Set.copyOf(toppings));
    }

    private List<Topping> append(Topping topping) {
        return Stream.concat(toppings.stream(), Stream.of(topping)).toList();
    }
}

Enter fullscreen mode Exit fullscreen mode

There's no way we can call withPineapple() on a CreamBase: we didn't declare the method, and compilation fails. However, imagine more combinations: an additional sweet base, or ingredients incompatible with others, e.g., no anchovies with pineapple. This would create additional states, represented as classes, and transitions, as methods. Soon, you'll face an explosion of combinations, which would require lots of boilerplate code. For example, with the anchovies/pineapple exclusion, you'd need two more base classes: TomatoBaseWithPineappleBuilder and TomatoBaseWithAnchoviesBuilder.

Java is not a good fit for creating smart builders. Let's see if other languages may improve the situation.

Modeling with Kotlin

The Kotlin model is pretty similar to the Java one:

sealed interface Crust
data object ThinCrust : Crust
data object ThickCrust : Crust

sealed interface BaseState
data object CreamBase : BaseState
data object TomatoBase : BaseState

sealed interface Topping
data object Ham : Topping
data object Olives : Topping
data object Potatoes : Topping
data object Pineapple : Topping

Enter fullscreen mode Exit fullscreen mode

The builder approach is pretty different, though:

class DoughBuilder internal constructor(internal val crust: Crust)

class PizzaBuilder<out S : BaseState> internal constructor( // 1 // 2
    internal val crust: Crust,
    internal val base: BaseState,
    internal val toppings: List<Topping>,
)

fun dough(crust: Crust): DoughBuilder = DoughBuilder(crust)

fun DoughBuilder.withCreamBase() =
    PizzaBuilder<CreamBase>(crust, CreamBase, emptyList())                         // 3
fun DoughBuilder.withTomatoBase() =
    PizzaBuilder<TomatoBase>(crust, TomatoBase, emptyList())                       // 3
fun PizzaBuilder<CreamBase>.withPotatoes() =
    PizzaBuilder<CreamBase>(crust, base, toppings + Potatoes)                      // 3
fun PizzaBuilder<TomatoBase>.withPineapple() =
    PizzaBuilder<TomatoBase>(crust, base, toppings + Pineapple)                    // 3
fun <S : BaseState> PizzaBuilder<S>.withHam() =
    PizzaBuilder<S>(crust, base, toppings + Ham)                                   // 3 // 4
fun <S : BaseState> PizzaBuilder<S>.withOlives() =
    PizzaBuilder<S>(crust, base, toppings + Olives)                                // 3 // 4
fun <S : BaseState> PizzaBuilder<S>.build() = Pizza(crust, base, toppings.toSet())

Enter fullscreen mode Exit fullscreen mode

  1. Generics on the parent class are possible in Java, but it lacks what comes next.
  2. out is Kotlin's syntax for covariance: a PizzaBuilder<CreamBase> can be used where a PizzaBuilder<BaseState> is expected. The upper bound : BaseState separately constrains S to BaseState and its subclasses.
  3. Function extensions are Kotlin-specific. Instead of creating different subclasses as in Java, we keep a single one, but extend each generic type with the relevant function.
  4. For transitions that are common to both bases, generics help us return the same type. This way, we can define the function once on the generic type.

If we were to add pineapple/anchovies exclusion as in Java, we would need a slight change in the existing hierarchy:

sealed interface TomatoBase : BaseState
data object Tomato : TomatoBase

Enter fullscreen mode Exit fullscreen mode

And then, add the states and the transitions:

data object TomatoWithPineapple : TomatoBase
data object TomatoWithAnchovies : TomatoBase

fun PizzaBuilder<TomatoBase>.withPineapple(): PizzaBuilder<TomatoWithPineapple> = TODO()
fun PizzaBuilder<TomatoBase>.withAnchovies(): PizzaBuilder<TomatoWithAnchovies> = TODO()

Enter fullscreen mode Exit fullscreen mode

Finally, you'd define the exclusive transitions on the new types with extension functions.

In Kotlin, unlike Java, adding a new constraint requires only new type markers and extension functions — the growth is linear, not combinatorial.

Modeling with Rust

Rust isn't an Object-Oriented programming language, contrary to Kotlin. Let's examine what we can learn from it.

The model part is quite straightforward:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Crust {
    Thin,
    Thick,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Base {
    Cream,
    Tomato,
}

pub struct HasDough;
pub struct CreamBase;
pub struct TomatoBase;

pub struct Pizza {
    crust: Crust,
    base: Base,
    ham: bool,
    olives: bool,
    potatoes: bool,
    pineapple: bool,
}

Enter fullscreen mode Exit fullscreen mode

Interestingly enough, the design is pretty similar to Java's:

#[derive(Default)]
struct ToppingState {
    ham: bool,
    olives: bool,
    potatoes: bool,
    pineapple: bool,
}

pub struct PizzaBuilder<S> {
    crust: Crust,
    toppings: ToppingState,
    _marker: std::marker::PhantomData<S>,                                          // 1
}

impl PizzaBuilder<HasDough> {
    pub fn dough(crust: Crust) -> PizzaBuilder<HasDough> {
        PizzaBuilder {
            crust,
            toppings: ToppingState::default(),
            _marker: std::marker::PhantomData,                                     // 1
        }
    }

    pub fn with_cream_base(self) -> PizzaBuilder<CreamBase> {
        PizzaBuilder {
            crust: self.crust,
            toppings: self.toppings,
            _marker: std::marker::PhantomData,                                     // 1
        }
    }

    pub fn with_tomato_base(self) -> PizzaBuilder<TomatoBase> {
        PizzaBuilder {
            crust: self.crust,
            toppings: self.toppings,
            _marker: std::marker::PhantomData,                                     // 1
        }
    }
}

impl PizzaBuilder<CreamBase> {
    pub fn with_potatoes(mut self) -> Self {
        self.toppings.potatoes = true;
        self
    }

    pub fn with_ham(mut self) -> Self {
        self.toppings.ham = true;
        self
    }

    pub fn with_olives(mut self) -> Self {
        self.toppings.olives = true;
        self
    }

    pub fn build(self) -> Pizza {
        Pizza::new(
            self.crust,
            Base::Cream,
            self.toppings.ham,
            self.toppings.olives,
            self.toppings.potatoes,
            self.toppings.pineapple,
        )
    }
}

impl PizzaBuilder<TomatoBase> {
    pub fn with_pineapple(mut self) -> Self {
        self.toppings.pineapple = true;
        self
    }

    pub fn with_ham(mut self) -> Self {
        self.toppings.ham = true;
        self
    }

    pub fn with_olives(mut self) -> Self {
        self.toppings.olives = true;
        self
    }

    pub fn build(self) -> Pizza {
        Pizza::new(
            self.crust,
            Base::Tomato,
            self.toppings.ham,
            self.toppings.olives,
            self.toppings.potatoes,
            self.toppings.pineapple,
        )
    }
}

Enter fullscreen mode Exit fullscreen mode

  1. Phantom type. See section Phantom types below.

Java and Rust have fundamental differences in approaches: the former uses a runtime, and the latter compiles to native. Yet, from a pure language syntax point of view, Rust impl maps more or less to a Java subclass in this context.

We would get the same combinatory explosion for anchovies and pineapple.

Modeling with Gleam

Gleam is a recent programming language that runs on the BEAM. As of now, I'd say I'm proficient in Java and Kotlin, thus covering the JVM. Kotlin also allows JavaScript transpilation for the browser. I'm able to write quite a bit in Python, and consider myself a Rust newbie. The BEAM is outside my reach, and I wanted to widen my options. Erlang is dynamically-typed, and Elixir is a bit too esoteric. Gleam's syntax is a bit similar to Rust's.

Note: Given my limited knowledge of Gleam, I used Claude Code a lot in this section. I'll be happy to receive feedback from Gleam programmers as I'm still learning.

Types are declared as:

pub type Crust {
  Thin
  Thick
}

pub type Topping {
  Potatoes
  Pineapple
  Ham
  Olives
}

pub type Base {
  CreamBase
  TomatoBase
}

pub type Cream {
  Cream
}

pub type Tomato {
  Tomato
}

pub opaque type Pizza {                                                            // 1
  Pizza(crust: Crust, base: Base, toppings: List(Topping))
}

Enter fullscreen mode Exit fullscreen mode

  1. opaque is Gleam's way to hide the constructor from outside the module

Then the builder looks like:

pub opaque type DoughBuilder {                                                     // 1
  DoughBuilder(crust: Crust)
}

pub opaque type PizzaBuilder(base) {                                               // 1
  PizzaBuilder(crust: Crust, base: Base, toppings: List(Topping))
}

pub fn dough(crust: Crust) -> DoughBuilder {
  DoughBuilder(crust: crust)
}

pub fn with_cream_base(builder: DoughBuilder) -> PizzaBuilder(Cream) {
  let DoughBuilder(crust: crust) = builder
  PizzaBuilder(crust: crust, base: CreamBase, toppings: [])
}

pub fn with_tomato_base(builder: DoughBuilder) -> PizzaBuilder(Tomato) {
  let DoughBuilder(crust: crust) = builder
  PizzaBuilder(crust: crust, base: TomatoBase, toppings: [])
}

pub fn with_potatoes(builder: PizzaBuilder(Cream)) -> PizzaBuilder(Cream) {        // 2
  let PizzaBuilder(crust: crust, base: base, toppings: toppings) = builder
  PizzaBuilder(crust: crust, base: base, toppings: [Potatoes, ..toppings])
}

pub fn with_pineapple(builder: PizzaBuilder(Tomato)) -> PizzaBuilder(Tomato) {     // 2
  let PizzaBuilder(crust: crust, base: base, toppings: toppings) = builder
  PizzaBuilder(crust: crust, base: base, toppings: [Pineapple, ..toppings])
}

pub fn with_ham(builder: PizzaBuilder(a)) -> PizzaBuilder(a) {                     // 3
  let PizzaBuilder(crust: crust, base: base, toppings: toppings) = builder
  PizzaBuilder(crust: crust, base: base, toppings: [Ham, ..toppings])
}

pub fn with_olives(builder: PizzaBuilder(a)) -> PizzaBuilder(a) {                  // 3
  let PizzaBuilder(crust: crust, base: base, toppings: toppings) = builder
  PizzaBuilder(crust: crust, base: base, toppings: [Olives, ..toppings])
}

pub fn build(builder: PizzaBuilder(a)) -> Pizza {
  let PizzaBuilder(crust: crust, base: base, toppings: toppings) = builder
  Pizza(crust: crust, base: base, toppings: list.reverse(toppings))
}

Enter fullscreen mode Exit fullscreen mode

  1. Hide the constructor from outside the module
  2. Specific transitions
  3. Common transitions

Adding anchovies topping exclusive to pineapple would require: adding the new states as types and the transitions as functions. As in Kotlin, it would be a linear code expansion, thanks to the generic PizzaBuilder(a) parameter and return type for common transitions.

Phantom types

Kotlin, Rust, and Gleam all allow a generic type on PizzaBuilder. While the syntax is different and the naming differs, phantom data in Rust, phantom type in Gleam, the concept stays the same: use generic types to enforce typing at compilation time, while letting go of them at runtime.

Here's the Kotlin code above, simplified:

class PizzaBuilder<out S : BaseState>

fun PizzaBuilder<CreamBase>.withPotatoes() = TODO()
fun PizzaBuilder<TomatoBase>.withPineapple() = TODO()

Enter fullscreen mode Exit fullscreen mode

The magic is the S generic in the PizzaBuilder. It allows grafting extension functions based on this type. withPotatoes() only applies when S is CreamBase, withPineapple() when it's TomatoBase. Without a generic type, you can't constrain extension functions. However, S isn't used in any other place: it's effectively a phantom type, hence the name.

Gleam splits between types and functions. While this design is quite different from Kotlin's, it also enables phantom types to constrain functions depending on the phantom type.

The Rust compiler effectively disallows unused types, so you need to reference it somehow. Hence, you need a _marker: std::marker::PhantomData attribute: it's a zero-cost abstraction, discarded in the compiled code and useful only for the compiler. The leading underscore is a Rust convention that suppresses Rust's dead-code warning.

Note that despite Java having generics, its lack of extension functions or related mechanisms prevents attaching methods to a specific generic type.

Conclusion

Static typing is the solid foundation that prevents illegal state transitions. Yet, you need more to design a model that doesn't degrade quickly. Without phantom types and a way to leverage them to bind specific functions to them, each new state adds combinatorial complexity. The exact mechanism depends on the language: extension functions in Kotlin, impl blocks in Rust, and constrained parameter types in Gleam.

The complete source code for this post can be found on Codeberg.

To go further:


Originally published at A Java Geek on April 19th, 2026.