< back to blog

Moving your bugs forward in time

Five patterns that use type systems and immutability to surface software defects before runtime—and the tradeoffs they introduce.

A green beetle falling through a blue tunnel

Early in my career, I judged a codebase by what it could do today. I wanted to ship quickly and crank out code. Tests were nice to have, “works on my machine” felt like reasonable acceptance criteria, and I’m not sure I even knew what “maintainable” meant. I considered myself a pretty 1337 coder.

Then I watched several codebases grind toward a halt. No one could understand them. Extending or debugging them took too long. Some became so fragile that engineers were afraid a change would produce a spectacular bug in production.

Over the years, that experience made me more curmudgeonly deliberate about code structure and how I judge its success.

Optimize for the next engineer

Maintainability is now one of the most important words in my vocabulary. I care about what the code will be able to do tomorrow. More importantly, I care about what $nextEngineer will be able to make it do—not only what I can make it do today.

A maintainable foundation lets a team keep extending and debugging its software as the product grows. That quality matters more to me when evaluating engineering candidates than how many lines of code someone can produce or how quickly they can produce them. The question is whether those lines will help current and future engineers sustain their pace.

Move preventable bugs toward compile time

If I had to distill my approach to maintainability into one sentence, it would be this:

Structure your code so that you catch preventable bugs at compile time rather than at runtime.

I call that moving bugs forward in time. The compiler cannot catch every defect, and this approach does not replace tests. It targets the classes of mistake that a language and its type system can express, then surfaces them while the engineer still has the relevant code in front of them.

The five patterns below pursue that goal from different angles. They are not novel ideas of my own. Many are core features in languages such as Kotlin, Rust, and Clojure. Kotlin, in particular, emphasizes them while remaining practical and approachable.

Credit belongs to the language designers who brought these ideas to the foreground. Writing in several languages can also challenge your assumptions about software design. I haven’t written Clojure in years, but the time I spent with it did more to improve my engineering skills than anything else I’ve done. Lessons from one language can often improve code in another, even when the second language does not provide the same feature directly.

Historical context: This post reflects my experience and the language and library landscape in November 2022. Treat its examples as design illustrations, not current API documentation, and check the latest language and SDK references before applying them.

Five patterns for surfacing bugs earlier

1. Static types

This is a tough sell for some people who love Python, Ruby, Clojure, and other dynamically typed languages. I might never convince them, but runtime type mismatches have burned me too many times to change my position.

Dynamically typed languages avoid some of the ceremony of declaring types on method signatures. That can speed up early prototyping, keep attention on business logic, and support flexible functions that operate on data. Those are real benefits.

My concern appears as the codebase grows. I have repeatedly seen an engineer pass the wrong object type into an unfamiliar function. The application accepts the code and crashes only when that call runs. If it reaches production, the team may face a customer-visible outage, rollback, or hotfix.

Tests can catch this mismatch, but only if someone anticipates and covers the relevant path. Within a statically checked boundary, the compiler can reject that mismatch before the code enters a pull request. Tests still need to cover behavior; the type checker takes responsibility for a structural constraint it can prove.

That tradeoff has convinced me not to default to dynamically typed languages even for prototypes. Prototype code often becomes product code because it already exists. If you are writing production Python, Ruby, or JavaScript, consider the type-checking tools in those ecosystems: type hints and mypy for Python, an incremental move to TypeScript for JavaScript, or the RBS type annotation system introduced with Ruby 3.0.

2. Null safety

Tony Hoare famously called null references a billion-dollar mistake. In languages without compile-time null safety, a missing value can cause a null pointer exception at runtime. Defensive code may instead accumulate boilerplate null checks at every function boundary.

C#, Kotlin, and TypeScript let developers declare values that cannot be null. Java offers Optional as an alternative to returning null. These tools let the type system represent the possibility of absence explicitly.

My rule of thumb is that a nullable variable may be a code smell when absence is not a meaningful state. First ask whether you can structure the code to avoid it. If absence is meaningful, model and handle that possibility explicitly with the null-safety tools your language provides.

3. Immutable variables and data structures

This one takes practice: fewer variables and types need to be mutable than you might expect.

I first encountered the idea while learning Clojure, where expressing a mutable object is difficult. I found it implausible. Once I tried it, I saw the maintainability benefit.

When both a variable and its data structure are immutable, the line that defines the value tells you that another part of the program cannot later change it. A mutable value asks you to investigate much more:

  • Did a statement modify it?
  • Did a function receive it by reference and mutate it?
  • Must you inspect those functions to understand its current state?
  • Could another thread have changed it concurrently?

That hidden state increases the work required to reason about unfamiliar code. Immutability removes those mutation paths. Many languages provide immutable local bindings, such as Kotlin’s val and TypeScript’s const, as well as data structures such as Kotlin data classes and C# records.

Collections can be the difficult case. Loops commonly build arrays or maps by mutating them. Functional operations such as map, filter, and reduce or fold offer another approach. A fold can rewrite this mutable collection:

The snippets preserve their original 2022 form and focus on the design contrast rather than copy-and-paste compilation. They omit surrounding types and project setup.

val pepperNames = listOf("jalapeno", "habanero", "serrano", "poblano")
val pepperNameLengths = mutableMapOf()
for (pepperName in pepperNames) {
    pepperNameLengths[pepperName] = pepperName.length
}
// from here forward we need to be cognizant about the pepperNameLengths map being mutated!‍

into an immutable fold:

val pepperNameLengths: Map = pepperNames.fold(mapOf()) { accumulator, pepperName ->
    accumulator + (pepperName to pepperName.length)
}
// no mutable map to worry about here!

The second version has no mutable map to track after construction. It also makes the transformation—pepper name to name length—the central operation.

4. Persistent collections

When a coworker recommended immutable collections, I worried about performance and memory. Would adding one key require a copy of the entire map?

Persistent collections address that concern. I first encountered them in Clojure, and I recommend Rich Hickey’s talk on the topic. In brief:

  • A persistent data structure is immutable, but operations such as put, add, and remove return another immutable version.
  • Tree-based implementations can share most of their structure between versions. Updating one item copies only the nodes on the path to that item. Efficient implementations may clone no more than about 4 nodes even when a tree contains millions of nodes.

Libraries such as Java PCollections and C# Immutable Collections do that work for you. Their persistent structures retain immutability while avoiding a full copy for every change.

This is especially powerful in concurrent programs. Multiple threads can consume the same immutable collection without locking to protect it from mutation. The constraint simplifies application code and removes that source of lock contention.

The tradeoff is that you must choose an appropriate persistent implementation and learn its performance characteristics. “Immutable” is a design property, not a promise that every operation or library has identical cost.

5. Algebraic data types and exhaustive pattern matching

These constructs have different names across languages. Kotlin calls them sealed classes. I think of them as an enumeration of types known at compile time, where each member can carry its own properties and methods.

An example from the Momento Cache API I was working on in November 2022 makes the design concrete. The following code illustrates the pattern; it is not current SDK documentation.

A cache get can produce three conceptually different outcomes:

  • A hit with a value
  • A miss with no value
  • An error with diagnostic information

Without an algebraic data type, an API might return one GetResponse object with a status enum plus fields for every possible outcome. The value, errorCode, and errorMessage fields must be nullable or optional because each exists only for some statuses:

enum class GetResponseStatus {
    HIT, MISS, ERROR
}
data class GetResponse(
    val status: GetResponseStatus,
    val value: String?,
    val errorCode: Int?,
    val errorMessage: String?
)

This representation makes the caller connect each status with its valid fields. A caller that assumes HIT and accesses value on another outcome can fail at runtime. Kotlin’s null-safety rules force some handling of the nullable value, but the caller still has to reason about which fields belong to which status.

A Kotlin sealed hierarchy can encode those relationships without nullable fields:

sealed interface GetResponse {
    data class Hit(val value: String) : GetResponse
    object Miss : GetResponse
    data class Error(val errorCode: Int, val errorMessage: String) : GetResponse
}

Each outcome now has a distinct class with only its relevant properties. Pattern matching through Kotlin’s when expression lets a caller handle them:

val getResponse: GetResponse = cacheClient.get("myCacheKey")
when (getResponse) {
    is GetResponse.Hit -> {
        println("Cache hit! ${getResponse.value}")
    }
    GetResponse.Miss -> {
        println("Cache miss!")
    }
    is GetResponse.Error -> {
         println("Error! ${getResponse.errorMessage}")
    }
}

The value property exists only on Hit, so callers cannot access it until they have established that the response is a hit. Kotlin can also check whether a when expression covers every member of the sealed hierarchy.

That exhaustiveness matters when an API evolves. If an engineer adds another GetResponse subtype, every exhaustive when that lacks the new branch stops compiling. Without that check, the engineer must find and update each usage manually. The type system moves that omission from a future runtime path to the current change.

Choose the earliest useful feedback

Maintainability helps future engineers understand and extend a codebase safely. Static types, explicit absence, immutability, persistent collections, and exhaustive variants have proved especially valuable in my recent projects because they reduce how much hidden state and unwritten knowledge an engineer must carry.

They do not make runtime testing obsolete. Compilers can enforce represented types, states, and branches; tests still exercise business behavior, integrations, and conditions the type system does not model. The practical goal is to give each class of defect to the earliest feedback mechanism that can catch it reliably.

On your next change, look for one invalid state that the code currently permits. Ask whether a type, an immutable value, or an exhaustive branch can make that state harder—or impossible—to express.