Generalization as discipline

22 Sep, 2026 · 13 min read
Contents

General code comes out better than code cut to fit one job, and its authors are the first to benefit. When we cannot afford all of it, the way down runs against instinct: work out the ideal shape first, then cut what today does not need, and keep a plan for putting it back.

Generalization as discipline

This is the first of a series on designing APIs. A good API answers to many criteria: a minimal surface, minimal implementation effort, a good developer experience, low cognitive load, orthogonality that lets it combine with other APIs, and more. The series explores some of them. This post starts with completeness: what the ideal shape of an API is, and how to cut it down on purpose. The posts after it take the sources of that shape one at a time, and then how to express it.

A word on the word. When I search for “API design” today, practically every result is about HTTP: how to lay out the endpoints of a REST service, which verbs, what JSON. That is one instance of the subject, and a narrow one. This series is about functionality: what an API should expose, and how to expose it. The examples are mostly in JavaScript, the language I write most, but the principles apply to many languages, all the imperative ones included.

Why general code is better

Working for hire, we mostly build applications, and an application has one job. Cutting corners for that one job is the right call: the specific problem pays the bill, the general one is unpaid work, and “good enough for these needs” is a perfectly good bar. The reflex “don’t build it if you don’t need it now”, known as YAGNI (you aren’t gonna need it), fits app work.

It fits because in app work we own every caller. When an interface turns out wrong, we can find its uses, change them, and ship the lot together. So a cut corner stays cheap: we can always go back for it. That is true only while we control the whole codebase, and it stops being true once strangers start calling our code.

Code written for strangers is the opposite case, and the reflex inverts. A library serves unknown callers in unforeseen situations, so there is no single use case to cut corners for: we have to cover a wide front. That is more work, and the payoff is that the result is usually better than the in-house version would have been, because building for unknown callers forces us to think in primitives instead of features. Generality has its costs, and adopting someone else’s general library has more; a separate post looks at them.

Two of my libraries make the point:

  • stream-json is built as primitives — a parser that emits a token stream, and filters and streamers that consume it — rather than as one JSON-to-objects feature. The payoff showed up five years after its first release. stream-csv-as-json parses CSV into the same token protocol, so everything downstream of stream-json worked on CSV unchanged. The non-JSON parts were later split off into stream-chain, which is now the foundation under both.
  • node-re2 wraps Google’s RE2 so that a regular expression fed hostile input cannot blow up in backtracking. Checking a rogue input usually needs only test() or exec(), but callers also used replace(), with a function as well as a string, and more. So the API follows RegExp as closely as we could make it: the constructor takes a RegExp and honours its flags, and the String methods are there. The implementation differs where it has to. RE2 has no backreferences or lookahead, which is how it keeps matching linear, and we cannot bridge that difference and would not want to; the documentation says so. The result is a drop-in replacement in most cases. A replacement cannot guess which part of the API any caller uses.

The wide front has a second payoff: bugs. Supporting several unrelated users surfaces defects that a single caller would never reach, and they get fixed. I pointed my reasoning oracle, a tool of mine that checks the logic in a program’s conditions and control flow, at a large C++ library from a company I don’t work for. One set of findings was about my own tool. The sweep crashed the oracle three ways and exposed a gap in how it reads C++ that no JavaScript codebase had exercised, all fixed the next day. The library showed the same effect from the other side. It is heavily used and rock-solid by necessity, and the code in use was clean. Its streaming part, which its internal users had not switched to yet, had two dozen defects. An outside user found them before the maintainers and their customers paid for them.

With no narrow case to lean on, we also end up reasoning about the code across the whole domain instead of testing a few points of it. That argument has its own home (TDD as religion and Loop and if invariants), so it stays there.

Even in app work, YAGNI fits only one-off projects. A project that will live, maintained and extended for years, has unknown callers of its own: the developers who come after, frequently ourselves, more experienced and wiser. A corner cut in the foundation’s API is a hole they build around. The people laying that foundation are in the same boat as the people writing a library for strangers.

The app itself stays specific. Parts of it can be framed as libraries, though, and those libraries can be generalized. They form the app’s foundation, and a generalized foundation is better and more stable. A library also gathers algorithms and decisions into one place instead of leaving them spread across the app. A bug gets fixed once, a critical algorithm gets validated and verified where it lives, and every part of the app reuses the same code.

Work out the ideal shape first, then cut

To cover a wide front we first have to know all of it. The move is simple to state and easy to skip: figure out the whole thing first, then trim.

  • Start by asking what the complete set of operations is. It has two parts: the primitives, operations enough to get from any valid state of the concept to any other, and the frequently used operations that are hard to implement well with the primitives alone. Don’t filter the list by what today’s app happens to use.
  • Then cut what today does not need. Dropping an operation we understand is cheap and reversible. Discovering a missing one after the API is in use is neither: callers have built around the gap, and the late addition rarely composes with what is already there.
  • The direction decides the outcome. Cut from a known whole and we are left with a coherent subset. Grow from today’s minimum and we are left with a pile of special cases that never quite add up.

A fair objection: isn’t working out every operation before shipping any of them over-engineering? It would be, if the output were code. It is a list — an afternoon with a whiteboard, most of which we then cut on purpose. What ships stays as small as it ever was; we know what we left out, and why, and where it would go.

In my own designs the cut follows a pattern. I tend to keep the minimal shape that can still reach any valid state from any other, though not as a hard rule, and implement the richer operations on top of it, unless a direct implementation performs better. With a getter and a setter I can update a property any way I want, so the pair covers the primitives, and it is minimal. It offers none of the common operations, though: if the property is a vector, every matrix operation is mine to reinvent each time I modify it. Common operations that are hard to implement efficiently I provide from the start. What I cut, I mark: a stub, a comment, or a documented point of growth. When the need arrives, the plan and the provisions for it are already there.

Take an HTTP library that ships get(url) and post(url, data), both returning the decoded body, because those are all our app calls. It is incomplete, and for that app it is enough. Working out the rest shows which additions will be mechanical. PUT fits the signature of post(), and DELETE fits that of get(). HEAD does not fit. Its response carries no body, its result is the status and the headers, and “returns the body” has no place for them. So the plan we write down is a fuller form of each call, returning the status and the headers along with the body. HEAD will live there.

One qualifier on “cheap and reversible”: the cut is free only before the first caller arrives. After the first release, removing an operation costs what adding a missing one costs, for the same reason — somebody built on it. That puts a deadline on the completeness pass: the window closes when we publish. It is also the honest answer to “why not version it later?” We can, and sometimes we must, but a new version fixes a shape we got wrong, and every caller has to migrate to get the fix. Getting the shape right first is what keeps versions rare.

A case of a shape corrected after release. An onboarding task once handed to me as an easy way in: upgrade the app’s router, a popular library, so the lead could use its new features. Run the tests, two hours at most. The app was on version 1, and the current release was 4. Version 4 broke the app, and so did 3 and 2 when I tried them: missing APIs, changed signatures, changed semantics. I went to the forums and to GitHub to understand why, and as I remember it, every major version came with the same explanation: the original ideas had proven wrong, and correcting them took a drastically different API. In the end I rewrote the app’s routing for version 4 over several days, while new teammates wondered how a simple task could take so long. Later I switched to react-enroute, a small router by TJ Holowaychuk (@tjholowaychuk on X) that already supported the regular-expression route patterns the popular one lacked back then. It worked, and I have had no such problems since.

Reach every valid state, from any other

The primitives give us the floor for whether an API is complete: can we get from any valid state of the type to any other, using only the API? A weaker reading — every state reachable from one fixed starting point — passes APIs that cannot make the trip. The design work goes into the second part of the set: the frequent operations that are hard to build well.

Take a linear container. We should be able to add items, remove them, and rearrange them into any valid order, including sorted order. If some valid arrangement exists that the API cannot produce from the current one, the API is incomplete, however tidy it looks.

The failure mode has a familiar shape: “don’t do that — we don’t support it.” Sometimes that is a legitimate, deliberate limit. But each instance marks a missing operation, and it is worth knowing whether it is a considered cut or an oversight.

A considered cut can look like this. A container with add() and an iterator accumulates objects for an algorithm and is thrown away when the algorithm is done. It is useful, and it fails the test while passing the weaker reading. Every state can be built up from empty, but nothing can be removed, so it can overflow, and it cannot be reused. For its job, that is fine. The B+ tree in Cheating as a programming discipline is the same kind of cut: built once and only read after that, it never needed deletion. Written as a library for strangers, it would need deletion: we would not own the callers.

Adding clear() to the container with add() makes it reusable, and it now passes the test. Empty it, add the items back in whatever order we want, and any state is reachable from any other. Reaching a state and reaching it cheaply are separate questions, though. clear() takes everything at once. remove(item) takes one item and leaves the rest alone, which is more flexible and more precise. Say the container is a stack, so add() is push(). Its pop() is remove() for the top item, a very common operation on stacks: without it, changing the top item means emptying the stack and rebuilding it. pop() makes nothing newly reachable, and it makes the short trips cheap — which is what the second part of the set is for.

JavaScript’s own standard library has gaps of this second kind: Map and Set arrived without map() or filter(), and for years Set had no method to intersect one set with another. They get a post of their own later in this series.

Where does the complete set come from?

From a structure somebody has already worked out. We inherit a proven operation set with known costs instead of deriving the operations by hand. The payoff is fewer surprises later: fewer missing operations a caller runs into, less glue written around them. That is also the discipline in the title: we generalize toward a structure that already exists, and leave imagined uses to YAGNI. A few kinds of such structures come up again and again:

  • A field’s own traditional foundation, like double-entry in bookkeeping, which its experts know well.
  • Mathematics, which prescribes the operations for a type by its algebraic structure.
  • Generic containers, which share most of their operations whatever they hold.
  • CRUD (create, read, update, delete), which prescribes the operations for objects and the collections they belong to, physical or virtual.

A field’s experts can usually name its operations. Representing them is our part: the data structures and the algorithms.

The list is open. Arguably mathematics, containers, and CRUD are traditional foundations too, those of our own field. And there are more structures to borrow: many synchronization primitives, such as a mutex, a semaphore, or a reader-writer lock, can be expressed in terms of entering and leaving, with very different implementations underneath.

An API can also be section-based: one property behaves like a container, another is plain arithmetic, and the API combines the matching sets mechanically. Every source works the same way: the structure supplies the operation set, and we have to recognize it. The next two posts in this series take mathematics and CRUD.

Summary

Four points to carry forward:

  • General code comes out better, and its authors benefit first. With callers we do not control, strangers or our own successors, there is no single use case to cut corners for. We think in primitives, and a wide front finds bugs a single caller would miss.
  • When we cannot afford all of it, start from the ideal shape and cut down, instead of growing from a minimum. What we cut, we mark, so the plan for putting it back is already there.
  • Completeness is one mark of a good API, and it starts with a concrete test: can the API get from any valid state to any other? It ends with the frequent operations that are hard to build well. “We don’t support that” marks a missing operation, cut on purpose or by oversight.
  • The shape comes from a structure somebody has already worked out, such as math, containers, CRUD, or a field’s own foundation, and often from several combined.