Skip to content
Ben Christel edited this page Oct 29, 2022 · 22 revisions

The Timeless Way of Programming

Confidence • Satisfaction • Joy

In this book, bold headlines alternate with supporting details.
To get the gist, you can just read the headlines.

As you try to put these ideas into practice, you'll undoubtedly have questions. That is a good time to come back to the book and thoroughly understand the details.

Should You Read This Book?

This book is for application programmers working in most any modern language.

My experience is primarily with web applications, so I will focus on those.

  • frontend, backend, or fullstack
  • greenfield or legacy
This book covers the most useful ideas from the last 70 years of computing, distilling them into practical techniques.

Computers have changed a lot since the 1950s. But people haven't. We still have the same psychological needs, the same strengths and weaknesses, and the same habits of thought that we had then. These unchanging human factors are the thing that makes programming really challenging. Phrasing code for the machine, once we know what code to write, is the easy part. The hard part is learning about the system, organizing our thoughts, and communicating with each other.

So, even though almost everything about the technology—programming language, architecture, user experience—has changed, old ideas about how to make software are often still good, if you distill them to their essence.

This book covers the following topics:

  • Programming paradigms and when to use each. Good and bad ways to approach OOP.
  • Software architecture
  • Organizing code
  • Test-driven development
  • Type-driven design
  • Creating good abstractions
  • Domain modeling
The programming language used in this book is TypeScript.

TypeScript is the most popular programming language in 2022. It has an extremely expressive type system that makes it suitable for doing type-driven design, an idea that appears in the second half of this book. It also supports the three major programming paradigms, so it is ideal for exploring the tradeoffs and synergies among them.

Why You Should Read This Book

I'm writing this book because I want you to be happy.
When people and computers interact, the result can be satisfaction or suffering. Too often, it is suffering.

(insert illustration of two human/computer systems: one with smooth information exchange (defect-free, clear, fast, focused, familiar, simple, self-verifying) producing satisfaction, joy, comfort, and mastery; and one with turbulent information exchange (buggy, opaque, slow, distracted, alienated, complex, "magic") producing doubt, loathing, exhaustion, and fear).

When programmers suffer, the software suffers—and vice versa. The resulting feedback loop destroys the system.

When programmers hate the code, they stop taking care of it. They'll hold out hope for a rewrite and discount any possibility that things could be incrementally improved. Improving bad code is often mind-numbingly tedious, slow work—and a little bit better is nowhere near good enough. So why bother?

The problem is, that rewrite may be a long time coming. In the meantime, programmers may simply leave the company so they don't have to deal with the code anymore. The only way out of this situation is catastrophe, or heroic effort.

But when the software is healthy, the programmers are happy. The system maintains and heals itself.
In the best case, programming feels simple, steady, down-to-earth, and nearly effortless.

When I say "effortless", I don't mean to imply "boring". I mean the effortlessness of playing a piece of music or a video game that is exactly at your skill level. You have to be engaged in it, or you'll fail—but engaged is all you have to be. As you proceed through the task the information you need is ready to hand—it seems that things are just where you needed them to be. Each move you make is swift and sure. But you're not hyper-focused or in a trance. It feels like the most ordinary thing in the world.

(quote from Zen and the Art of Motorcycle Maintenance?) (the ancient masters were profound and subtle...)

If we are trying to get this feeling, few things matter more than the shape of the code.

If we are learning about an unfamiliar part of the codebase, the shape of the code dictates the shape of our (initial) thoughts. The concepts represented in the code, and the relationships among them, become the basis for our mental model of how the program works.

Bad code stymies our efforts to understand it, while good code helps.

Guidelines for how to write good code often get treated as hard-and-fast rules, and therefore misapplied.

There are many proposed guidelines for how to write "good code". But they're often misunderstood, and applied in ways that don't actually make the code easier to understand or change.

I think this happens mainly because the "rules" for writing good code aren't context-sensitive enough. The person who comes up with the rule and publishes it has tried it, perhaps, in only a few situations. They don't anticipate all the ways in which their context is different from other programmers' contexts, and so the rule gets disseminated without the necessary warning labels.

This book is different: it describes not rules, but tools.

The truth is, there are no rules for how to write good code. There are only mental tools and techniques that are appropriate in certain contexts. This book makes those contexts explicit. For every guideline, I also list exceptions where it doesn't apply.

I also document the mental models of software that are enabled by certain coding patterns. This, I think, is crucial.

The purpose of "good code" is to enable us to learn faster and structure our thoughts better. But most books on code quality stop at "good code" and don't discuss "good thoughts". This omission is the second reason I think coding guidelines get misapplied: the programmers applying them don't have a clear idea of what the guidelines are supposed to achieve, so they have no way of accurately judging if their efforts to improve the code have made it better or worse.

This book goes beyond the code: it covers ways of thinking about software that will improve your ability to understand it, whatever its shape.

The result is more than the sum of its parts.

I call this book "The Timeless Way of Programming" because it really does describe a way of programming: a coherent philosophy. That philosophy is a chorus of harmonious ideas from the annals of computing history and beyond, which is why I claim that it is timeless. The ideas transcend the boundaries of technology and language. I expect they'll remain relevant far into the future.

I've used this way of programming in many languages and on many different projects. I hope this book helps you to do the same.

Interlude

Always remember: the code works for you. You don't work for the code. All software was written by people just like you and mostly, those people are trying to help you, though often (being human) they fall short. When you know that—really know it, deeply, you can grok the code. Having grokked it, you can change it. Having changed it, you can judge the results. If you don't like them... well, that's what undo is for. This is the beginning of being in control.

Make the code work for you.

Part I: Science

Mental Models

A mental model is your inner picture of some aspect of reality. It tells you what kinds of things exist, and the various ways they can relate to each other. Mental models are what allow us to make accurate predictions about the world. When I use the word model in this book, I usually mean "mental model" (I'll tell you if I mean the software kind).

The models you use change how you "see" the code. A good model can make the difference between finding a given piece of code confusing and finding it clear. When we can model code accurately, we can change it easily and reliably, without introducing bugs.

This book also contains many heuristics, which are rough guidelines for improving code by making it easier to model. Beware, though—they aren't hard and fast rules, and they don't improve the code in every situation. The important thing is not just to learn the heuristics, but to understand when and why they're useful. Most of the bad software I have worked on was created by applying the wrong "best practices" in situations where those practices weren't appropriate. Don't let your systems meet the same fate they did!

The techniques in this book are specific tricks you can use when programming to accomplish some short-term goal. Each of the techniques in this book helps with at least one of four things:

  • understanding programs (that is, building models)
  • designing programs so they can be modeled more effectively
  • predicting the results of changes to the code, or
  • changing the code.
The models in this book are incomplete, but they're all useful

To quote George Box, "All models are wrong, but some models are useful." Models are simplified, artificial views of reality. The simplification and artifice are what make the models useful, but they also mean that a given model won't accurately describe every situation. Throughout this book, as I refer to various models, I point out the situations where I know them to be wrong or incomplete. They may, of course, have other flaws that I don't know about. But I hope that by calling your attention to the known flaws, I'll prepare you to spot the ones I don't know about yet.

Although all of the models are wrong, they are wrong in different ways, so they complement each other. Each fills gaps left by the others. By learning to use all of the models you can get a comprehensive picture of your software systems.

To work well, models must be shared by all the programmers working on the software.

If the members of a team don't share mental models, the system starts to fall apart at the seams. Programmers retreat into their own isolated silos of knowledge, working on just the part of the code that they know well. Bugs creep in because the siloed chunks of code don't talk to each other in an organized way, and no one has a comprehensive picture of how the whole system works. To shield your software from this fate, you and your teammates must have similar mental models of the code.

If your teammates already have models of the code that are working well for them, don't try to impose mine. Learn theirs instead. The list of models in this book is almost certainly not complete, and their models probably work well too. They may be the only models that work for your codebase.

Likewise, if you discover new models that work well, don't hesitate to use them and share them with your team.

Model 1: The Behavior of a Software System

The first goal of software engineering is to make software that does what we intend.

Define SoftwareSystem

Code that DoesWhatYouIntend.

This is an easier goal to agree on than code that is "correct" or "high-quality". What "correct" and "high-quality" mean will depend on your context. But we can all agree that if code doesn't do what we intended it to do, it's no good.

Definitions of SoftwareQuality often focus on conformance to requirements. For application software, this is problematic, because we almost never have a complete and correct description of "requirements" before we start writing code. We discover "requirements" as we build the software and observe people using it. Furthermore, the "requirements" are not really requirements, in the sense of "behaviors the software must exhibit to be considered a success". They're more like options: "this is one possible way of solving the user's problem". We're constantly weighing the cost and value of these options, some of which may be incompatible with each other, to design the product.

In order to know if a software system does what we intend, we first have to be able to describe what the system does. "What the system does" is called its behavior.
To model the behavior of a system, we first divide the system into components. Services, processes, devices, and people might all be modeled as components.

There are many possible divisions, at many different levels of granularity, depending on how detailed we're interested in getting. The simplest possible division is into two components: often the split is user+software, or client+server.

(Is it dehumanizing to model people as components? Well, as Alan Watts would say, that's the pessimist's view of it. The optimist's view is much more heartening, and will be elucidated toward the end of this book.)

The behavior of the system is the set of possible interactions among those components. An interaction is a sequence of discrete messages.
As an example, consider the behavior of a pocket calculator—the kind that a grade-school student might use.
In general, the behavior is an infinite set of possible interactions.

This is part of why developing software is so hard. If we imagine the set of all message-sequences arranged in a hyperdimensional space, then the task of defining the software's behavior is equivalent to sculpting a very complicated, infinitely large "shape" in that hyperdimensional space. That "shape" is the boundary separating desirable interactions from undesirable ones. We have to describe this shape indirectly, by writing code that will generate it, and that code has to be simple enough to fit in our heads.

The task sounds impossible, and perhaps it is, in general. But the saving grace is usually that our software's users are human, too, and so the behavior has to fit in their heads. This means that (if the user experience is well-designed) it should always be possible to intuitively grasp the software's behavior. Formally defining the behavior is the part that takes a bit more work.

The "behavior" model is useful, first of all, because it allows us to communicate with some precision about what the software should and should not do.
Second, the behavior model is useful because we can translate interactions directly into automated tests. These tests demonstrate that, at least in the few situations we've tested, the software does exactly what we intended it to do.

Of course, without knowing how the software is structured internally, we have no way of knowing if those tests say anything in general about the correctness of our code. If we have a test demonstrating that typing 1 + 1 = into our calculator produces 2, we can't necessarily conclude that 2 + 2 = 4 is working correctly. Ben Moseley and Peter Marks, in "Out of the Tar Pit", remark that "testing for one set of inputs tells you nothing at all about the behaviour with a different set of inputs," but I think they overstate their case. While their statement is formally true, in practice the situation is not quite as bleak as that. When code is simple, we can convince ourselves that it is correct without exhaustively testing every possible set of inputs. The importance of simplicity cannot be overstated. It is vital to science—in fact, we need simplicity to be able to form any kind of general picture of reality at all. The next section, which compares test-driven development to the scientific method, explains exactly how simplicity operates in the context of software development.

Model 2: Test-Driven Development Is the Scientific Method

Code expresses a theory about how to solve a problem.
A test is a reproducible experiment that can disprove the theory expressed by the code.

In other words, a test can quickly, reliably, and automatically tell you, "no, that won't work".

In order for a test to be valuable, it must be possible for the test to fail.
Tests are worth more when they give understandable failure messages.
Therefore, test your tests by watching them fail when the code is broken.
Beware, though, of writing incorrect tests.

While the analogy between TDD and science is illuminating, it would be a mistake to take it too literally. Scientists investigate nature, which always truthfully answers questions that are put to it by experiment. But in TDD, the "nature" for which we are developing theories is our understanding of what users expect the software to do. If this understanding is wrong, we'll write the wrong test assertions. The resulting "theory"—the code generated from the tests—would then be wrong, in the sense that even though it passes all the tests, it doesn't do what the users want. TODO: revise this section; it's confusing. Mention oracles. Cite WhyMostUnitTestingIsWaste

Example: Testing the Calculator

Model 3: The Set of Messages Exchanged By Two Components Is a Type

This model is incomplete in the sense that there are often restrictions on the valid values that can be exchanged—restrictions that cannot be expressed in the type system. E.g. {items: Array<Item>, activeItem: number} may have the additional restriction that activeItem must be a valid index into the items array.

Model 4: Processes

Processes are a useful model for computation—so useful that they are reified in our operating systems. So as a step toward discussing computational processes generally, we will discuss Unix processes as an example.

A process is the dynamic instantiation of a static program. A process is born when you start the program running, and dies when the program finishes. In Unix-like OSes, you can also kill processes, forcing them to stop. Many processes created from the same program can be running at the same time on one computer.

The state of a process consists of an instruction pointer, which indicates which piece of code is to be executed next, and the information the process is storing in memory. (Strictly speaking, the state also includes the values stored in CPU registers, but when programming in TypeScript, we almost never have to think about that.)

Processes are deterministic. If you know the state of a process, and the program it spawned from, you can flawlessly predict what it will do in the future, up until the point where it receives information from some external source. That source could be a file it's reading from, the output of some other process, the current time, or input from the user.

Model Q: The Dependency Graph

Model 4.1 Component Capabilities

This model is flawed because some things, like logging and performance monitoring, have effects on the world outside the process, but are allowed to occur in "pure" functions.

I call these special-case effects "instruments" since their purpose is almost always to probe the running system and get information about it.

In some circumstances, we might also model effectful components as merely stateful—such as when a component reads and writes a file on disk, but we can be reasonably certain that it is the only thing that will ever write to that file.

In all cases, the model of a given component's capabilities must be chosen based on how we want to reason about the system, not based on nitpicking about what the code is really doing.

Part II: Mathematics

Heuristic: Most messages sent to and from objects should contain only data, not object references.

Heuristic: Do Not Alter Data

Data, properly speaking, are records of observed facts. Changing a data value doesn't semantically make sense. It also doesn't make sense practically. The TypeScript typechecker simplifies its work by assuming that objects that are simply sets of key-value pairs are immutable. If you mutate these objects, you might introduce type errors that the typechecker won't catch! If you want mutability, use an object as defined in the Component Capabilities model, which only reveals its internal state in response to messages (realized in TS as method calls).

Model 4.2 Time is a Message

An assumption required by this model: synchronous code runs so fast we can consider it instantaneous. For many applications, this is an okay assumption. In JavaScript, which is single-threaded, this way of thinking about time is almost forced on us: since synchronous computation blocks UI interaction, long-running computations must either be offloaded to worker threads (which the main thread communicates with asynchronously) or broken up into small chunks of work that yield control back to the browser every few milliseconds.

Example: Oven Timer

Example: Game with a time limit

Example: Static Core Microprocessor

StaticCore

Model 5: Kinds of Tests

SoftwareTesting

Model 6: Organizing Code

Model 6.1: Model-View-Controller

Model 6.2 Policy and Mechanism

Model 6.3 Application and Infrastructure

Model 6.4 Reactive Architectures

Model 7: Extract-Transform-Load (Input-Processing-Output)

This model is based on ArchitecturalLayers and CodeCapability. The conceit of the extract-transform-load model is that we can divide all synchronous computation into three distinct steps: get inputs (extract), process them to calculate some result (transform) and output that result somewhere (load).

Part III: Organization

Heuristic: separate input, parsing, processing, presentation, and output.

Heuristic: Domain Logic, Infrastructure, and Error Handling

Use exceptions for infrastructural errors, and union types for domain errors (e.g. input and state validation).

Model 9: Software Development Activities

TODO: should this be model 1?

About half my programming time is spent learning.
A large chunk of the remaining time is spent communicating what I've learned.
The rest of the time I'm inventing new things—that is, solving novel problems computationally.
The time it takes to type in the code is insignificant compared to all of the other work—learning, communicating, and inventing. It's only 1 or 2 percent of the total.
With this in mind, it is obvious why Brooks' Law—that adding staff to a late software project makes it later—is true.
Most of our job is understanding—and 10 people can't understand something faster than one person can.

Given that learning and understanding are so fundamental to programming, let's spend a few pages considering the nature of understanding. The next chapter, on mental models, delves into this issue.

Sources of Confidence

  • InformalReasoning - c.f. OutOfTheTarPit
    • GregWilson cited some reasearch showing that reading the code finds more bugs per hour than testing
  • SoftwareTesting - passing tests make us more confident
  • AlgebraicType - proofs of some kinds of internal consistency, ruling out many errors that could happen in a program with dynamic types. If our typechecker outputs no errors, that makes us more confident.
    • easiest to see in a language where we can do an apples-to-apples comparison of typed and untyped forms, e.g. TypeScript vs. JavaScript.
  • CompositionalReasoning with AlgebraicProperties (i.e. "semi-formal" reasoning)

How the Confidence Sources complement each other

  • InformalReasoning
  • SoftwareTesting
    • Flaw: spot-checking is not a proof.
    • Flaw: process-external Effects are hard to test
    • Flaw: duplicate test coverage makes failures hard to interpret and coverage hard to analyze. The opposite, "over-mocking," leads to situations where all the tests pass but the system as a whole doesn't work.
    • Flaw: you're not the Oracle
      • e.g. you need to call an API that returns some complicated data. It's not clearly documented and you misinterpret the meaning of one of the fields when creating a Stub of the API. So your UnitTests pass, but the system as a whole is wrong.
      • Partial fix: ensure you only make this mistake once by transforming incoming data into a form that's self-documenting or otherwise well-documented, and hard to misuse. I.e. ParseDontValidate.
    • Summary: testing is complemented by TDD (writing the simplest code that passes the tests), an architecture that pushes effects to the boundaries or abstracts them behind contracts with convenient algebraic properties, a shallowly layered architecture, and a discipline of understanding the data that's passed across architectural boundaries.
  • AlgebraicType
    • Flaw: certain generic interfaces are very difficult or impossible to express in certain type systems. E.g. generic variadic functions.
      • This is a shortcoming of current, specific type system technologies, not the mathematics of types
      • Even proponents of dynamic typing rely on the idea of types to make sense of their programs—they just don't have automated tools to check their thinking.
      • Possible resolution: something like Clojure's spec? Then you can't write fewer tests, though.
    • Summary: algebraic types are complemented by tests.
  • AlgebraicProperty and CompositionalReasoning
    • Flaw: error propagation threatens the simplicity of algebraic properties when the implementor has process-external Effects.

Programming Tactics

The True Goal

The first goal is the sine qua non of the second.

A sense of oneness with your work. "Oneness" isn't quite the right word, and there are many possible near-synonyms: connectedness, identity, care, kindness. Oneness involves:

  • a feeling of Mastery: you are confident that you can get the code to do what you need it to do. When you get a feature request, you can often develop plans of action that will work.
  • a feeling of Compassion toward your fellow programmers, the users of your software, and the authors of your dependencies. Compassion also involves doing things that help your coworkers achieve oneness with their work (e.g. writing understandable code with APIs that are hard to misuse).
  • a sense of responsibility. If the code's Messy or has a Bug, you take it seriously and fix it. This kind of responsibility can't be forced on you by others. You assume it, naturally and inevitably, when you care about the code and the people it affects. If the other qualities of oneness are present, you'll find it easy to fix any problems you cause, so the responsibility won't be a burden.
  • non-Instrumentality. Instrumentality often appears when you try to get something for free, without putting time or attention into it, or identifying with it. E.g. suppose you use someone else's code that you don't understand very well to try to make a job easier. If what you try doesn't work, it's easy to get frustrated and blame the code or the other person, which causes everyone to suffer. An attitude of non-instrumentality both:
    • recognizes that you may have to put some learning effort in to get the benefit of using the code. Nothing is free.
    • is willing to let go of a dependency on bad code and reimplement the functionality, if that's the pragmatic option.
  • continuous attention and course-correction as you work.
Clone this wiki locally