2.2 CRUST: Consistent, Resilient, Unambiguous, Simple and Tiny

A well-regarded API typically packs several of the following traits. It is consistent, meaning it is idempotent[6] and has a similar signature shape as that of related functions. It is resilient, meaning its interface is flexible and accepts input expressed in a few different ways, including optional parameters and overloading. Yet, it is unambiguous, there aren’t multiple interpretations of how the API should be used, what it does, how to provide inputs or how to understand the output. Through all of this, it manages to stay simple: it’s straightforward to use and it handles common use cases with little to no configuration, while allowing customization for advanced use cases. Lastly, a CRUST interface is also tiny: it meets its goals but it isn’t overdesigned, it’s comprised by the smallest possible surface area while allowing for future non-breaking extensibility. CRUST mostly regards the outer layer of a system (be it a package, a file, or a function), but its principles will seep into the innards of its components and result in simpler code overall.

That’s a lot to take in. Let’s try and break down the CRUST principle. In this section we explore each trait, detailing what they mean and why it’s important that our interfaces follow each of them.

2.2.1 Consistency

Humans excell at identifying patterns, and we do so while reading as well. That’s partly the reason — besides context — why we can read sentences even when most of their vowels are removed. Deliberately establishing consistent patterns makes our code easier to read, and it also eliminates surprises where we need to investigate whether there’s a reason why two equivalent pieces of code look the same, even though they perform the same job. Could it be that the task they perform is slightly different, or is it just the code that’s different but the end result is the same?

When a set of functions has the same API shape, consumers can intuitively deduce how the next function is used. Consider the native Array, where #forEach, #map, #filter, #find, #some, and #every all accept a callback as their first parameter and optionally take the context when calling that callback as their second parameter. Further, the callback receives the current item, that item’s index, and the array itself as parameters. The #reduce and #reduceRight methods are a little different in that the callback receives an accumulator parameter in the first position, but then it goes on to receive the current item, that item’s index, the array, making the shape quite similar to what we are accustomed to.

The result is we rarely need to reach for documentation in order to understand how these functions are shaped. The difference lies solely in how the consumer-provided callback is used, and what the return value for the method is. #forEach doesn’t return a value. #map returns the result of each invocation, #filter returns only the items for which the callback returned a truthy value. #some returns false unless the callback returns a truthy value for one of the items, in which case it returns true and breaks out of the loop. #every returns false unless the callback returns a truthy value for every item, in which case it returns true.

When we have different shapes for functions that perform similar tasks, we need to make an effort to remember each individual function’s shape instead of being able to focus on the task at hand. Consistency is valuable on every level of a codebase: consistent code style reduces friction among developers and conflicts when merging code, consistent shapes optimize readability and give way to intuition, consistent naming and architecture reduces surprises and keeps code uniform.

Uniformity is desirable for any given layer in an application, because an uniform layer can be largely treated a single, atomic portion of the codebase. If a layer isn’t uniform, then the consumer struggles to consume or feed data into that part of the application in a consistent manner.

The other side of this coin is resiliency.

2.2.2 Resiliency

Offering interfaces which are consistent with each other in terms of their shapes is important, and making those interfaces accept input in different ways is often just as important, although flexibility is not always the right call. Resiliency is about identifying the kinds of inputs that we should accept, and enforcing an interface where those are the only inputs we accept.

One prominent example of flexible inputs can be found in the jQuery library. With over ten polymorphic overloads[7] on its main $ function, jQuery is able to handle virtually any parameters we throw at it. What follows is a complete list of overloads for the $ function, which is the main export of the jQuery library.

  • $()

  • $(selector)

  • $(selector, context)

  • $(element)

  • $(elementArray)

  • $(object)

  • $(selection)

  • $(html)

  • $(html, ownerDocument)

  • $(html, attributes)

  • $(callback)

Though it’s not uncommon for JavaScript libraries to offer a getter and a setter as overloads of the same method, API methods should generally have a single, well-defined responsibility. Most of the time, this translates into clean-cut API design. In the case of the dollar function, we have three different use cases.

  • $(callback) binds a function to be executed when the DOM has finished loading

  • $(html) overloads create elements out of the provided html

  • Every other overload matches elements in the DOM against the provided input

While we might consider selectors and element creation to play the role of getters and setters, the $(callback) overload feels out of place. We need to take a step back and realize that jQuery is a decade-old library which revolutionized front-end development due — in no small part — to its ease of use. Back in the day, the requirement to wait for DOM ready was in heavy demand and so it made sense to promote it to the dollar function. Needless to say, jQuery is quite a unique case, but it’s nevertheless an excellent example of how providing multiple overloads can result in a dead simple interface, even when there’s more overloads than the user can keep in the back of their heads. Most methods in jQuery offer several ways for consumers to present inputs without altering the responsibilities of those methods.

A new library with a shape similar to jQuery would be a rare find. Modern JavaScript libraries and applications favor a more modular approach, and so the DOM ready callback would be its own function, and probably its own package. There’s still insight to be had by analyzing jQuery, though. This library had great user experience due to its consistency. One of the choices in jQuery was not to throw errors which resulted from bugs, user errors in our own code, or invalid selectors, in order to avoid frustrated users. Whenever jQuery finds an inappropriate input parameter, it prefers to return an empty list of matches instead. Silent failures can however be tricky: they might leave the consumer without any cues as to what the problem is, wondering whether it’s an issue in their code, a bug in the library they’re using, or something else.

Even when a library is as flexible as jQuery is, it’s important to identify invalid input early. As an example, the next snippet shows how jQuery throws an error on selectors it can’t parse.

  1. $('{div}')
  2. // <- Uncaught Error: unrecognized expression: {div}

Besides overloading, jQuery also comes with a wealth of optional parameters. While overloads are meant as different ways of accepting one particular input, optional parameters serve a different purpose, one of augmenting a function to support more use cases.

A good example of optional parameters is the native DOM fetch API. In the next snippet we have two fetch calls. The first one only receives a string for the HTTP resource we want to fetch, and a GET method is assumed. In the second example we’ve specified the second parameter, and indicated that we want to use the DELETE HTTP verb.

  1. await fetch('/api/users')
  2. await fetch('/api/users/rob', {
  3. method: 'DELETE'
  4. })

Supposing that — if we were the API designers for fetch — we originally devised fetch as just a way of doing GET ${ resource }. When we got a requirement for a way of choosing the HTTP verb, we could’ve avoided the options object and reached directly for a fetch(resource, verb) overload. While this would’ve served our particular requirement, it would’ve been short-sighted. As soon as we got a requirement to configure something else, we’d be left with the need of supporting both fetch(resource, verb) and fetch(resource, options) overloads, so that we avoid breaking backward compatibility. Worse still, we might be tempted to introduce a third parameter that configures our next requirement. Soon, we’d end up with an API such as the infamous KeyboardEvent#initKeyEvent method[8], whose signature is outlined below.

  1. event.initKeyEvent(type, bubbles, cancelable, viewArg,
  2. ctrlKeyArg, altKeyArg, shiftKeyArg, metaKeyArg,
  3. keyCodeArg, charCodeArg)

In order to avoid this trap, it is paramount we identify the core use case for a function — say, parsing Markdown — and then only allow ourselves one or two important parameters before going for an options object. In the case of initKeyEvent, the only parameter that we should consider important is the type, and everything else can be placed in an options object.

  1. event.initKeyEvent(type, { bubbles, cancelable, viewArg,
  2. ctrlKeyArg, altKeyArg, shiftKeyArg, metaKeyArg,
  3. keyCodeArg, charCodeArg })

A key aspect of API design is readability. How far can users get without having to reach for the documentation? In the case of initKeyEvent, not very, unless they memorize the position of each of 10 parameters, and their default values, chances are they’re going to reach for the documentation every time. When designing an interface that might otherwise end up with four or more parameters, an options object carries a multitude of benefits:

  • The consumer can declare options in any order, as the arguments are no longer positional inside the options object

  • The API can offer default values for each option. This helps the consumer avoid specifying defaults just so that they can change another positional parameter[9]

  • The consumer doesn’t need to concern herself with options they don’t need

  • Developers reading pieces of code which consume the API can immediately understand what parameters are being used, since they’re explicitly named in the options object

As we make progress, we keep naturally coming back to the options object in API design.

2.2.3 Unambiguity

The output shape for a function shouldn’t depend on how it received its input or the result that was produced. This rule is almost universally agreed upon: you should aim to surprise consumers of your API as little as possible. There are a couple of cases where we may slip up and end up with an ambiguous API. For the same kind of result, we should return the same kind of output.

For instance, Array#find always returns undefined when it doesn’t find any items that match the provided predicate function. If it instead returned null when the array is empty, for example, that’d be inconsistent with other use cases, and thus wrong. We’d be making the consumer unsure about whether they should test for undefined or null, and they might end up being tempted to use loose equality comparison because of that uncertainty, given == null matches both null and undefined.

On the same vein, we should avoid optional input parameters which transform the result into a different data type. Favor composability — or a new method — instead, where possible. An option which decides whether a raw object such as a Date or a DOM element should be wrapped in an instance of jQuery or similar libraries such as moment before returning the result, or a json option which causes the result to be a JSON-formatted string when true and an object otherwise is ill-advised, unless there are technical reasons why we must do so.

It isn’t necessary to treat failure and success with the same response shape, meaning that failure results can always be null or undefined, while success results might be an array list. However, consistency should be required across all failure cases and across all sucess cases, respectively.

Having consistent data types mitigates surprises and improves the confidence a consumer has in our API.

2.2.4 Simplicity

Note how simple it is to use fetch in the simplest case: it receives the resource we want to GET and returns a promise that settles with the result of fetching that resource.

  1. const res = await fetch('/api/users/john')
  2. console.log(res.statusCode)
  3. // <- 200

If we want to take things a bit further, we can chain onto the response object to find out more about the exact response.

  1. const res = await fetch('/api/users/john')
  2. const data = await res.json()
  3. console.log(data.name)
  4. // <- 'John Doe'

If we instead wanted to remove the user, we need to provide the method option.

  1. await fetch('/api/users/john', {
  2. method: `DELETE`
  3. })

The fetch function can’t do much without a specified resource, which is why this parameter is required and not part of an options object. Having sensible defaults for every other parameter is a key component of keeping the fetch interface simple. The method defaults to GET, which is the most common HTTP verb and thus the one we’re most likely to use. Good defaults are conservative, and good options are additive. The fetch function doesn’t transmit any cookies by default — a conservative default — but a credentials option set to include makes cookies work — an additive option.

In another example, we could implement a Markdown compiler function with a default option that supports autolinking resource locators, which can be disabled by the consumer with an autolinking: false option. In this case, the implicit default would be autolinking: true. Negated option names such as avoidAutolinking are sometimes justified because they make it so that the default value is false, which on the surface sounds correct for options that aren’t user-provided. Negated options however tend to confuse users who are confronted with the double negative in avoidAutolinking: false. It’s best to use additive or positive options, preventing the double negative: autolinking: true.

Going back to fetch, note how little configuration or implementation-specific knowledge we need for the simplest case. This hardly changes when we need to choose the HTTP verb, since we just need to add an option. Well designed interfaces have a habit of making it appear effortless for consumers to use the API for its simplest use case, and have them spend a little more effort for slightly more complicated use cases. As the use case becomes more complicated, so does the way in which the interface needs to be bent. This is because we’re taking the interface to the limit, but it goes to show how much work can be put into keeping an interface simple by optimizing for common use cases.

2.2.5 Tiny surface areas

Any interface benefits from being its smallest possible self. A small surface area means fewer test cases that could fail, fewer bugs that may arise, fewer ways in which consumers might abuse the interface, less documentation, and more ease of use since there’s less to choose from.

The malleability of an interface depends on the way it is consumed. Functions and variables that are private to a module are only depended upon by other parts of that module, and are thus highly malleable. The bits that make up the public API of a module are not as malleable, since we might need to change the way each dependant uses our module. If those bits make up the public API of the package, then we’re looking at bumping our library’s version so that we can safely break its public API without major and unexpected repercussions.

Not all changes are breaking changes, however. We might learn from an interface like the one in fetch, for example, which remains highly malleable even in the face of change. Even though the interface is tiny for its simplest use case, — GET /resource — the options parameter can grow by leaps and bounds without causing trouble for consumers, while extending the capabilities of fetch.

We can avoid creating interfaces that contain several slightly different solutions for similar problems by holistically designing the interface to solve the underlying common denominator, maximizing the reusability of a component’s internals in the process.

Having established a few fundamentals of module thinking and interface design principles, it’s time for us to shift our attention to module internals and implementation concerns.


1. The dictionary definition might help shed a light on this topic: https://mjavascript.com/out/complex.

2. For example, one implementation might merely compile an HTML email using inline templates, another might use HTML template files, another could rely on a third party service, and yet another could compile emails as plain-text instead.

3. You can check out the Elasticsearch Query DSL documentation here: https://mjavascript.com/out/es-dsl.

4. The options parameter is an optional configuration object — that’s relatively new to the Web API — where we can set flags such as capture, which has the same behavior as passing a useCapture flag; passive, which suppresses calls to event.preventDefault() in the listener; and once, which indicates the event listener should be removed after being invoked for the first time.

5. You can find request here: https://mjavascript.com/out/request.

6. For a given set of inputs, an idempotent function always produces the same output.

7. When a function has overloaded signatures which can handle two or more types, such as an array or an object, in the same position the parameter is said to be polymorphic. Polymorphic parameters make functions harder for compilers to optimize, resulting in slower code execution. When this polymorphism is in a hot path — that is, a function that gets called very often — the performance implications have a larger negative impact. Read more about the compiler implications in this detailed article from Vyacheslav Egorov: https://mjavascript.com/out/polymorphism.

8. See the MDN documentation at https://mjavascript.com/out/initkeyevent.

9. Assuming we have a createButton(size = 'normal', type = 'primary', color = 'red') method and we want to change its color, we’d have to do createButton('normal', 'primary', 'blue') to accomplish that, only because the API didn’t have an options object. If the API ever changes its defaults, we’d have to change any function calls accordingly as well.