-.- --. .-. --..

Prefer using minimal-matching interfaces in TypeScript

An interface in TypeScript can be thought of the shape of arguments that a function needs in order to work. Often these are written by hand, and sometimes there might be tooling that generates the type definitions (.d.ts file, for instance) based off on a JSON spec.

Most of our backend systems at work are written in Java, which expose API specs for every API endpoint the UI consumes. We use a such a tool to generate the corresponding TypeScript definition files based off of these. A function that then directly uses these response types for its arguments, but one which may not use all the parameters, becomes harder to reuse.

Consider a type definition for the 422 Unprocessable Entity error response of GitHub’s v3 API. Consider this definition is auto-generated. Also consider that in the first version, the 400 Bad Request error response wasn’t yet defined. It may look something like the following:

enum ErrorCode {
    MissingField = 'missing_field',
    Invalid = 'invalid',
    Missing = 'missing',
    AlreadyExists = 'already_exists'
}

interface Error {
    resource: string;
    field: string;
    code: ErrorCode;
}

interface Error422 {
    message: string;
    errors: Array<Error>
}

Imagine we’re building a new UI for GitHub in some new Flavour Of The Month JavaScript framework, and imagine a function that iterates over the error messages and displays the message. If we directly used the types from this structure in their entirety, it could look something like this:

function renderErrorMessage(error: Error422) {
    return Framework.createElement('p', innerText: error.message);
}

This works very well…until we have to add handling for a 400 Bad Request error type. The new interface won’t have an errors array type, so the generic-sounding renderErrorMessage can’t be directly reused; it strictly expects the maximal type of Error422 with the errors property set to an array. This is despite of the fact that technically we know it works with the new object.

Although this looks like a trivial example, I’ve seen many such instances in our code-base, which has grown organically over the years with multiple contributors changing different portions of the app. In many cases, the programmer would jump through hoops getting the “generic” function to work with multiple of these complex interfaces.

A much better design in such cases, and for the renderErrorMessage function here, would be something like the following:

interface ErrorLike {
    message: string;
}

// Or:
// type ErrorLike = Pick<Error422, 'message'>;

function renderErrorMessage(error: ErrorLike) {
    return Framework.createElement('p', innerText: error.message);
}

renderErrorMessage({ message: 'Body should be a JSON object' })
renderErrorMessage({ message: 'Validation Failed', errors: [ { /* ... */ } ] })

As a thumb-rule it’s always a good practice to make the interface of the function take the minimal set of attributes and resist the temptation to use the mechanically-derived interfaces directly. Sounds obvious enough in retrospect!