Let the Type System do the Work

On bugs, tools, and human evolution.

Anyone who has had to maintain software will tell you that bugs are tricky. Understatement of the year, maybe, but I'm not just talking about tricky to spot and fix. Think about the ways in which bugs suddenly coalesce in corners of your application that had previously seemed bug-free. Let's be optimistic and say that most of the time, bugs are the accidental product of otherwise well-meaning and competent work. They just sort of sneak in. They burrow into the code, lying silently in wait for conditions to be just right before they spring out and wreak havoc, bathing in chaos and anguish as is their favorite past-time.

You can probably anticipate some causes of bugs. Some code feels more fragile than others, so you plaster it with comments hoping it will save future adventurers from the pitfalls you avoided. It won't, but hey you tried, right? But there are other breeding grounds for bugs that you don't even know exist. That either cannot be anticipated or that you ignore – certainly no one will be foolish enough to do that: whatever that is.

When I started programming, I assumed the only requirement for quality software was quality programmers. Over the years I've been lucky enough to work with many quality programmers, though, and I've noticed: bugs still happen.

Of course looking back it's obvious that I was naive: having vastly underestimated the complexity of software in the real world and the non-technical challenges involved in software engineering. Looking forward – at the risk of still sounding naive – I believe there exists a path to better software quality. The first stone on that path is to realize that human engineers are fallible, and that any system founded upon a need for engineers to "do everything right 100% of the time" is doomed to fail. Instead, we will solve this problem in the most fundamentally human way: improve our tools.

Rather than asking how do we make better programmers that cause fewer bugs, we should be asking how do we make our tools make bugs impossible?

Tools of the trade

I want to define tools in an abstract way. Languages, compilers, IDEs, and static code analyzers are technical tools in a very real, self-apparent way. But I also want to include things like design methodologies and best-practices. These are mental tools. Frameworks in which to organize our thoughts and ultimately shape the code that we write. The programmer in the modern age has access to the most flexible and capable technical tools yet devised (I think it's safe to say), but I believe our mental toolset lags behind. (Without going on a huge tangent as to why, I highly recommend Bret Victor's talk "The Future of Programming". It's an excellent meditation on the development of programming and the human element.)

I'm not going to exhaustively list the tools that we need to develop to improve code quality – primarily because I'm not creative enough to imagine them all, but also because I'm driving towards a take-away, practical example. I think the most commonly overlooked code quality tool is your language's type system.

I'm staying out of the strongly- vs. weakly-typed language debate here; this is by no means intended as an "X is better than Y" article. Simply put: if your language happens to have a type system, use it!

So you have a type system

The standard intro to Object Oriented Design generally revolves around modeling real-world objects: cars and bicycles and things. Much derision has since been heaped upon this approach, and I think it's fair. Sure, you do sometimes model real world objects with OOD, but I would hazard to guess that most programmers don't find themselves using it this way. One of the problems with this approach, I believe, is that it's easy to take the type system for granted. You forget or just never really think about the advantages you gain from wrapping information in a type. Types just are, because bicycles just are. It's easy to toss primitives around unless you're working with "a whole bicycle".

The example I'm going to use is based in game programming – because that's where I spend my hobby time – but I think this applies just as well to other domains. One of the primary concerns in game development is coordinate information. If we (for simplicity) limit ourselves to 2D games, there's a vast collection of X's and Y's to pass around: screen sizes, world sizes, actor widths, actor positions, mouse click positions, and so on. If you have a grid or hex map, you're dealing with translating certain X's and Y's into a different plane of X's and Y's.

The most basic approach is just to pass around floats for all of this. If you get a little bit more fancy, you have a Tuple or Vector type that you use for all of these, possibly just to assist with the obligatory math.

This is a breeding ground for bugs just waiting to happen.

Let's say you define a class that represents the Player's character on the screen. You need to know where to put her and how big she is, so you might define the constructor like this:

class Player(var position: Vector2, var size: Vector2)

All well and good. Later you instantiate your heroine like this:

val player = new Player(new Vector2(100, 100), new Vector2(50, 50))

No problems here. The first noticible crack in the system comes after a few weeks vacation away from this code. You come back, and you want to change the starting point of the player. Will you remember which Vector2 to change?

Or maybe you'll write that code like this:

val startingPosition = new Vector2(100, 100)
val size = new Vector2(50, 50)
val player = new Player(startingPosition, size)

That's certainly more obvious, but it's awfully verbose.

Let's say a few more weeks of development go by and you're making classes for all the other entities in the game: enemies, power ups, things like that. Suddenly you decide you like putting the size as the first parameter in the constructor, so to keep consistent you go back and refactor your Player class to follow suit. If you have a flawless memory, you'll have no problem remembering to switch the order of parameters in the instantiation line. Or maybe you're human and you forget. Maybe you remembered to change 49 out of the 50 places you needed to change it. Well then you just introduced a bug and the compiler sat there quietly and let you do it.

Small types

I think the problem here is that you forgot you had a type system. Structurally there is no difference between a Vector2 used for size and a Vector2 used for position, but contextually they are completely different. If you leverage Small Types (as I'm calling it) you can differentiate all the various use-cases for X's and Y's:

abstract class Vector2[T : Numeric](val _1: T, val _2: T)

case class Position(val x: Float, val y: Float) extends Vector2[Float](x,y)

case class Offset(val dx: Float, val dy: Float) extends Vector2[Float](dx,dy)

case class Size(val w: Float, val h: Float) extends Vector2[Float](w,h)

Now, if you define your Player like this:

class Player(var size: Size, var position: Position)

Your compiler will complain if you failed to refactor this:

val player = new Player(Position(100, 100), Size(50, 50))

What you will have done is made an entire class of bugs impossible.

Other code becomes far more expressive. Instead of this HexGrid interface:

trait HexGrid {
  def getCenterOfHexel(i: Int, j: Int): Tuple2[Float, Float]
  def getHexelForPixel(x: Float, y: Float): Tuple2[Int, Int]
}

You can write this:

trait HexGrid {
  def getCenter(h: Hexel): Pixel
  def getHexel(p: Pixel): Hexel
}

Now the intended purpose of these coordinate translations cannot be doubted or accidentally mis-used.

In conclusion

Instead of passing around context-less primitives and exchangeable types, use types with the context information baked right in.

Of course not all languages are going to make it super comfortable to use this approach. Scala's brevity and flexibility to define multiple types in a single file is awfully nice here. Java would make it a pain, but even so I think it's worth the effort.

This is just one example where a small modification in design methodology can yield far more expressive, less bug-prone code. Without being constantly tripped up by bugs that your compiler should be catching, you'll be free to focus on the hard problems.