Chapter 1: Elm: Constraints That Free You

Remember that Penguin example from the introduction? That kind of exhaustive checking runs through everything Elm does. Let me show you what this means for everyday development.

Let’s say you refactor a type. Say you rename a field from status to orderStatus (not an unreasonable thing to do). You update the code, run your tests, and ship it, thinking all is well and good. A week later, though, production errors start rolling in: Cannot read property 'status' of undefined.

You missed one usage. It was buried in an error handler that only runs when a specific edge case triggers. TypeScript didn’t catch it because that file had an any type. Your tests didn’t catch it because they didn’t quite cover that exact scenario. ESLint was silent because the code was syntactically fine, as far as it could tell.

Oversimplification aside, I’m sure you’ve experienced something similar. I propose this is not about your being a bad developer, or that you suck at your job. I mean, you ran the tests, you checked and double-checked your work, and even had that one guy who’s extra strict do the code review! But in a codebase of any size, it’s impossible to keep every usage of every field in your head, let alone spot these things in a PR. You rely on tools to catch what you forget, but these tools can only do so much.

React Recommends, Elm Requires and Enables

React and Elm are essentially heading in the same direction, but taking quite different paths to get there.

Look at how React has evolved:

  • Hooks moved us toward functional components and immutable state
  • Redux brought predictable state management to the mainstream (Bonus point: Redux was actually inspired by Elm!)
  • TypeScript went from optional to essential for most serious projects
  • Server Components push side effects to the server

Each change pushes React toward functional programming principles. Immutability, pure functions, explicit state management–the React community now considers all of these best practices. No React developer worth their salt has side effects in their map or reduce, after all! Right?

I’ve heard more than one React developer say something like: “Good React code in 2026 looks suspiciously like Elm code from 2016.”

React recommends functional programming. Elm requires it. And the constraints that feel limiting at first? They’re what let you stop chasing bugs through state mutations and start solving the actual problem.

In React, you can still mutate variables, mix paradigms, create runtime errors. The community discourages it; JavaScript can’t stop you.

In Elm, these things are simply impossible1. The language won’t compile if you try to mutate data. And there’s no such thing as throwing an exception–the compiler rejects Debug.todo when you build with the --optimize flag, so it can’t sneak into production.

React’s flexibility is a genuine strength. But it comes with a cost: you have to maintain the discipline yourself, and it gets harder as your codebase scales.

When Constraints Give Freedom

This comes up all the time when debugging:

1 const user = { name: "Ada", age: 29 };
2 someFunction(user);
3 console.log(user.name); // What's the name now?

The answer is simple, and annoying: you can’t know without reading someFunction. Maybe it mutates the user. Maybe it doesn’t. Maybe it mutates it conditionally. Maybe you never get to the console.log at all because of a runtime exception. You have to trace through the code to be sure. TypeScript’s Readonly<T> helps at the surface level, but deep immutability requires recursive utility types and you basically have to stay on guard all the time (and as casts can always punch a hole through your defenses).

I’ve spent hours debugging issues where data was mutated in unexpected places. A function I thought was safe was actually changing my state without my knowing, and the resulting bug only appeared in specific conditions (and my tests didn’t catch it).

Now look at the Elm equivalent:

1 user = { name = "Ada", age = 29 }
2 
3 -- This isn't valid Elm syntax, whether inline or in a function:
4 user.name = "Grace"  -- ERROR: won't compile
5 
6 -- The right way:
7 updatedUser = { user | name = "Grace" }  -- Creates a new record

The compiler makes mutation impossible. When you pass user to a function, you know it comes back unchanged. Not because you trust the function author (that would be too brittle) but because the language prevents it. All mutation. If you’ve used Rust, you know the distinction between a variable and a mutable variable matters. In Elm, they’re all immutable.

You stop wondering “who changed this value?” because nothing can. That question simply doesn’t exist in Elm.

Debugging Gets Boring (In a Good Way)

In React, when state is wrong, you trace backward: where was this set? What changed it? Did something mutate it accidentally?

In Elm, when state is wrong, you look at your update function. That’s it. That’s the only place state changes. If the state is wrong, the logic in update is wrong. No hidden mutations or stale closures. No wondering if some other component changed something, because it couldn’t possibly do so.

At my client’s production app (a 125k+ lines Elm codebase), we’ve had entire months with zero runtime exceptions originating from our Elm code2.

Though the developers on my team are great, that’s not the main reason. I’m quite confident this is it: the compiler catches those errors before the code runs!

An icon of a info-circle1

Elm Hook

You know that moment when you refactor a type but forget to update one place that uses it? In Elm, you literally cannot compile until you fix every single usage. The compiler won’t let you ship the incomplete refactor. It’s impossible to “forget one spot.”

Refactoring with Confidence

All of this pays off most when you refactor.

Say you need to add a new state to your application–a Paused state for a game, or a Refreshing state for data loading. In React, you’d:

  1. Add 'paused' to your TypeScript union type
  2. Search the codebase for places that check the state
  3. Update each one, hoping you found them all
  4. Test manually, hoping you caught the edge cases
  5. Ship and monitor for bugs
  6. Hope and/or pray?

(To be fair: TypeScript’s discriminated unions help here. But they’re opt-in, and escape hatches like any and type assertions mean you can never be fully sure you caught everything.)

In Elm, you:

  1. Add Paused to your union type
  2. Try to compile
  3. The compiler lists every place that needs updating
  4. Fix each one until compiler gives you a green light
  5. Deploy

I refactored a complex state machine recently–47 places needed updates. The compiler found all 47. I fixed them one by one. When the code compiled, I deployed it. No bugs or forgotten edge cases. The compiler had verified completeness, and that was it.

This isn’t a guarantee of correctness (I can still have logic bugs!) but it is a guarantee that every code path handles every state. No gaps. No undefined behavior at runtime.

Architecture You Don’t Have to Enforce

If you’ve read about Clean Architecture or SOLID principles, you know the ideas are sound. Single Responsibility, Dependency Inversion, separation of concerns. Good stuff, and I’m all for it!

But they’re also discipline. You have to remember to follow them. Code review has to catch violations, and it’s way too easy to cut corners when you’re rushing.

The Elm Architecture enforces these patterns by default. Not as guidelines, but as requirements. The architecture separates View, Update, and Model–Browser.sandbox and Browser.element require them as separate functions, so Single Responsibility is just how the code works. Mutation is impossible and side effects go through Cmd, which means your functions stay pure whether you’re thinking about it or not. And pattern matching forces you to handle every case, so exhaustive handling comes for free.

Where other languages offer SOLID (or whatever acronyms float their boat3) as “best practices” you should follow if you’re disciplined, in Elm they’re mandatory. The compiler enforces what code review can’t.

What This Costs You

Elm’s strictness has real costs.

The ecosystem is smaller. React has thousands of libraries. Elm has hundreds. You’ll find yourself writing more from scratch.

The learning curve is steeper. Functional programming is different if you’re coming from JavaScript, and concepts like pattern matching and union types take time to internalize.

Your team needs to learn. Hiring is harder. Onboarding takes longer. Not every developer wants to learn a niche language.

You lose flexibility. Sometimes you just want to mutate a value and move on. Elm won’t let you. You have to do it the “right” way, even when the shortcut would probably work.

The language hasn’t had a release since 2019. That can look alarming at first glance, but in practice it means remarkable stability. Your Elm code from five years ago still compiles and runs without changes. Whether you see this as a cost or a feature depends on your perspective, but you should know about it going in.

For some projects, these costs aren’t worth it. If you’re prototyping, exploring, or building something simple, React’s flexibility is valuable. You want to move fast, not satisfy a strict compiler. Doubly so if React is already in your bones: when you need speed, familiar tools win. And honestly, for most projects today, React is still the pragmatic choice, and that’s perfectly fine.

But for other projects (production applications where bugs are expensive, complex state machines, financial tools, healthcare systems) Elm’s guarantees are worth the upfront cost.

And, to be completely honest: At this point I personally prefer Elm even for the occasional whimsical side-project that won’t hurt a fly no matter how hard it crashes. As you’ll hopefully discover for yourself before too long: Elm is kind of addictive, and fun to work with!

What Elm Teaches You

The best thing about learning Elm is what it does to the rest of your code.

After spending time with Elm, mutable state starts looking suspicious. You begin designing types that prevent bugs instead of just documenting intent. You write better React, better TypeScript, better everything–because the functional thinking becomes yours, not just something a linter enforces.

I genuinely think Elm is the fastest route into functional programming. Not because it teaches you monads and functors4, but because it makes functional programming impossible to avoid. Try to mutate a variable? Compiler says no. Try to ignore a case? Compiler says no. Try to sneak in a side effect? Nope. You can’t cheat your way around it, so you learn to think functionally.

Compare this to learning FP through Haskell or OCaml. Those languages are powerful, but they’re also large and complex. Haskell has lazy evaluation, type classes, monad transformers, dozens of language extensions. By the time you understand enough to build something useful, months have passed.

Elm’s syntax fits in your head in a weekend. No classes. No inheritance. No async/await, no promises, no null, no undefined. Just functions, types, and one architecture pattern. That smallness is exactly why you pick it up fast.

And you’re building in a domain you already know. If you’re coming from React, you understand components, events, state updates, and you’re familiar with the DOM. Elm uses different mechanics, but the same concepts. The output is still web, so you can focus on learning functional programming instead of a whole new platform.

Even if you never use Elm professionally, the habits stick. Modeling with types, making illegal states unrepresentable, treating data as immutable–you carry these with you into any language. And if you eventually pick up Haskell, F#, or OCaml, you’ll find you already understand the core concepts.

Enough philosophy. Next chapter, we write the same app in React and Elm, side by side.


  1. To be 100% accurate, you could generate a runtime error by triggering an underlying JavaScript divide-by-zero exception. An actual division by zero in Elm is not enough, though; you’d have to do remainderBy 0 {whateverNumber} or modBy 0 {whateverNumber}. And it’s technically possible to get a Stack Overflow if you (mis-)use extreme recursion without tail call optimization.↩︎

  2. JavaScript code communicating through ports can still throw exceptions on its side, though these won’t crash the Elm runtime itself. When I say “zero runtime exceptions,” I mean zero from the Elm code.↩︎

  3. Bonus point: you might also have to argue with your peers that following SOLID in particular or being mindful of architecture in general is even worth it, especially in frontend projects. (It is, btw, but the point is not everyone agrees.)↩︎

  4. Elm never even mentions monads or functors by those names. You’ll use them (Maybe.andThen is monadic bind) but the language doesn’t make you learn the vocabulary to get work done.↩︎