Why I Want Static Typing
Working in a large, dynamically typed codebase teaches you that it’s not a matter of if a bug due to a type error will make it into production, nor is it a matter of when. It’s a matter of how often. You know the drill: you forget to update a namespace in some forsaken part of the code while refactoring, miss a call to a constructor because it’s hidden behind reflection magic, or simply make a typo in a variable. However it happens, these bugs march into production – right past your unit tests, integration tests, and QA testing – at a regular cadence.
These are the kinds of bugs that just don’t happen in a statically typed language. Reference a namespace that doesn’t exist? Compiler error. Call a constructor with the wrong type of values? Compiler error. Misspell a variable name? Compiler error. This class of simple, obvious bugs can easily be caught by a compiler with static typing.
This is not why I want static typing.
Imagine, for a moment, a language with no types. I don’t mean a language with no explicit declaration of types, like Python or JavaScript. Those still have types under the hood, the syntax of the language just pretends that they don’t exist. In Python, you can pass a value to the type()
function to see that it does in fact have a type.
Nor do I mean a language with a single type, like Lambda calculus. I mean a language where the data has no explicit interpretation. It’s just there. I think the closest you can get to a typeless language is assembly. Down on the machine itself, the bits in a location in memory don’t have any type. They aren’t integers or floating point numbers or pointers or characters, they are just bits. It’s completely up to the instructions operating on the data to decide what to do with it. There is nothing stopping you from writing four characters in a row, then turning around and multiplying that same memory by 0.00035, then using that same memory again as a pointer. It wouldn’t make any sense, but you could do it.
There is a reason why nearly all programmers work in something other than assembly, and it’s not because that would be too much typing. Given an efficient short-hand, many programs would be much shorter in assembly than a high-level language. We prefer high-level languages because we need the text of the program to mirror our ideas in order for us to effectively work with the code. In assembly, you have to keep track of what type you want to interpret each location in memory as and make sure to always use the appropriate type of instruction. If you were writing directly in assembly, this would invite a lot of errors, add a lot of mental overhead, and obscure the meaning of the program.
It’s nicer to work in C than in assembly. Once you’ve told the compiler the type of a variable, it keeps track of that for you and emits assembly instructions for the appropriate data type. This makes programming much simpler. You use +
to add two numbers together, regardless of whether they are floats, unsigned longs, chars, or doubles. You don’t have to think about that detail when reasoning about the overall program. You just know that the two numbers are being added and can move on from there (of course, abstractions leak, but that’s another topic for another time). C manages to provide this abstraction (and others) while maintaining a very simple mental model. It has a very good abstraction-to-complexity ratio, which I think is a large part of why it has been so successful.
From C, type systems grew in complexity. Remember, complexity is a Bad Thing. We want things to be simple. Big, cumbersome type systems like in C++ were seen as a burden (perfectly reasonable, if you ask me) – too many angle brackets, too many compiler errors. Somewhere around the late 1990s it became in vogue to have language where you don’t write out the type declarations. Instead, you defer the problem to runtime and hope that nothing crashes. Such languages are often known as dynamic language or dynamically typed languages.
There is a lot to love about the dynamic languages like Python and Ruby. They have a simple syntax (especially in comparison to C++) and make it easy to try out ideas. These dynamic languages also include features not always found in compiled languages, such as automatic garbage collection or closures. With no static typing they have no type declarations, further leading to a lightweight syntax. Translating an idea into code is a much more efficient process in these languages because there is less to worry about. Since the code has to express fewer inessential details, the logic of programs stands out more clearly. Perhaps needless to say, these languages became popular.
These days, the tide is starting to turn back the toward static typing. The latest crop of languages has been mostly statically typed. Facebook created Hack, Google created Go, Microsoft created TypeScript, and Mozilla created Rust. These new languages hold on to many of the features that make dynamic langauges so practical, but add back static typing. Thankfully, though, they aren’t adding back all of the work. Depending on the language, they employ lighter type systems (e.g. Go) or gradual typing (e.g. Hack), and most use type inference to cut down on the work of specifying types. Even Java has grown a very mild form of type inference.
I have a pet theory that the wave of dynamic languages was an accident of history. People became fed up with the work of dealing with types before tools like type inference became popularized, leaving dynamic languages as the only place to turn. Had type inference become popular 20 years earlier, dynamic languages might have never become as popular. It seems that now that wave is coming to an end, followed by a wave of languages with some static typing and type inference. I don’t know if the next wave after this one will revert back to dynamic languages, but I suspect not.
Even though I’m here to argue in the favor of static typing, I’ll admit that for small programs – the ones you can fit in your head all at once, perhaps even programs a little larger than that – dynamic languages can often be the best option. (They are not always the best option, since sometimes there are performance concerns, but they often are.) At a small scale, the problems that static typing can solve tend to be non-issues. The added programmer efficiency of dynamic languages is a worthwhile trade-off.
However, the trade-off changes as your codebase becomes larger. You’ll remember that I started this discussion by talking about large codebases. The codebases I have in mind are a few 100 kloc each. In codebases like those, it becomes clear that the small-scale efficiency offered by dynamic types becomes dwarfed by issues that crop up. If you graph the productivity of dynamic and statically typed languages versus the codebase size, you’d see dynamic languages start at a high productivity in small programs, but then sink dramatically as the program size gets larger. Meanwhile, statically typed languages start out at lower productivity than dynamic languages, but they maintain that productivity. As the productivity of dynamic languages crashes, there is a crossover point where the program is large enough in size to make statically typed languages more efficient.
This is not just because statically typed languages help find some kinds of errors. I already said that’s not the main reason I like statically typed languages. When the language is statically typed, it means the compiler knows (before runtime) what the type of any given value will be. This is the important bit. Yes, that allows it to catch errors that arise from the incorrect use of types, but that’s only a small part of the picture (yet it’s often the only part of the picture dynamic language proponents talk about). If that was the only thing the compiler could do for you, having unit tests in a dynamic language would do just as good a job of catching errors. Better, probably.
No, what it means for the compiler to know about the types is that it can start helping you program. It allows intelligent auto-completion. It allows the compiler to automatically pick from several, identically named functions depending on what type of value is required. Many kinds of performance optimization are possible when the types are known before hand. IDEs can perform usage search that’s more accurate than a text search. Refactoring tools can correctly update references automatically. Linter tools can find deeper errors. Basically, I want the compiler to know as much as possible about the program so it can be more like a helpful assistant than a mindless translator. I want the J.A.R.V.I.S of compilers.
I’d like to point out one final thing you can get from static typing. This one has the caveat that not all languages with static typing can do this. Imagine you are in charge of a program to control the trajectory of a lander headed for Mars. Some teams worked in metric, but a few had to work in imperial units for some reason. You discover that it has a bug where in one place it is adding feet to meters without a conversion, giving a meaningless result. That bug is fixed, but how do you know that there aren’t similar bugs lurking in other parts of the code? The problem is due to the fact that different types of values (meters and feet) have a common representation in the machine (floating point), which ends up meaning that they have the same type in the language. They shouldn’t. In languages like Haskell, you can create new “subtypes” of a type.
The language now prevents accidental combination of the two since they are different types. Better still, the types map better to real life. A meter is a different type of thing than a foot (or a radian, temperature kelvin, or anything else you might use a float to store). Whenever the program uses the type Meter instead of Float, it is saying more clearly what the data actually means. This should make programs easier to reason about. It also means that you can change the data format used to represent meters (perhaps an Int turns out to be good enough) with a single change.
If you think the feet/meters example a little silly, consider another: HTML sanitization. If you have separate types for “raw” and “html” strings, you can avoid forgetting to escape user-input text (or accidentally double-escaping something). Getting that right is important for security, so it’s nice when the compiler can check that you’ve done it right with every single build.
There are languages like Coq that verify a proof of your program’s correctness while compiling. The one catch is that such languages are horrendously difficult to get anything done in. However, it’s possible to get a good portion of the benefits of proving with a small fraction of the work. One way to think of a static type system is as a system that checks a proof that your program contains no type errors.
And there, I think, is the best glimpse into what I want. I want the compiler to reason with you about the program. I want it to verify the soundness of my reasoning. I want it to think of things I didn’t think of (maybe there is a simpler way to write a piece of logic that I didn’t see). I want it to be able to answer questions for me that would be tedious and error-prone for me to try to answer myself. I want the computer to be an extension of my thoughts, doing the parts that are hard for me to do.
Ultimately, these features are only worthwhile when they provide value to the programmer. Static typing is one of those tools that takes more up-front work, but it has good returns in the long run. There is certainly a crossover point before the “long run” starts. Before that point, dynamic languages are generally the way to go. Newer popular techniques like type inference help to push that crossover point earlier, making it make sense to use a statically typed language in more cases.