⚒️ Coalesce!

Saving on test cases by leaning on your type system

At WorkTango, we mandate 100% code coverage. Over the years, we have found a few handy, type-safe helpers to help us save on writing test cases where the scenario would add no value.

The Problem

Consider this function:

interface Attribute {
  name: string;
  displayName?: string; // <-- Optional
}

/**
 * Given an at tribute, returns its display name if present. Otherwise, returns
 * the attribute's name.
 */
function getAttributeDisplayNameOrName(attribute: Attribute): string {
  return attribute.displayName || attribute.name;
}

We use static analysis tools to determine coverage branches. This function, because it uses what is in effect a ternary, has two logical branches: the displayName branch, and the name branch. (It’s roughly equivalent to attribute.displayName ? attribute.displayName : attribute.name.) To achieve 100% coverage, our test cases (assuming Jest) would need to be:

it("returns display name if present", () => {
  expect(
    getAttributeDisplayNameOrName({ name: "name", displayName: "displayName" })
  ).toEqual("displayName");
});

it("returns name if display name is not present", () => {
  expect(getAttributeDisplayNameOrName({ name: "name" })).toEqual("name");
});

Doing that once or twice is fine. In critical cases, you might be totally OK exercising both branches of code just to be 100% sure our business requirements are met. But over a large enough codebase, there are dozens, often hundreds of small needs for default values, and that’s a lot of extra tests.

Type system to the rescue

TypeScript lets us lean on the type system to reduce the amount of low-value test code we have to write. Since the pattern we’re presented with is merely “use value X if it’s present, otherwise use Y,” we can assume that Y is a narrowed version of X. Another, more robust way to do this is to evaluate each argument from left to right, returning the first non-null, non-undefined value until we get to the last one.

In addition, we can use tuples, rest operators, and generics to ensure that our inputs and outputs don’t allow any funny business. So long as we avoid any-scripting our code, we can be sure that the right-most argument passed to our function is the resulting type we’re keeping, since it’s the default.

This, it turns out, is a fairly common pattern with a known name: coalesce!

Let’s see it:

/**
 * Given an arbitrary list of arguments, returns the first value (evaluated left to right)
 * that is not undefined or null. The last argument must be a non-null, non-undefined
 * default.
 *
 * @example
 * const title = coalesce(article.shortTitle, article.title);
 *
 * @example Takes any number of arguments
 * const status = coalesce(
 *    company.defaultPersonStatus,
 *    department.defaultPersonStatus,
 *    "ACTIVE"
 * )
 */
function coalesce<T>(
  ...args: [...(T | null | undefined)[], T extends null | undefined ? never : T]
): Exclude<T, undefined | null> {
  for (const arg of args) {
    if (arg === null || arg === undefined) {
      continue;
    }
    return arg as Exclude<T, undefined | null>;
  }
  // Unreachable
  throw new Error(`Unreachable`);
}

Excellent. Looks good, so let’s write out some type tests:

const stringOrUndefined: string | undefined = undefined;
const stringOrNull: string | null = null;
const stringOrNullOrUndefined: string | null | undefined = null;
const aRealString = "the default";
const test1$alwaysAString: string = coalesce(
  stringOrUndefined,
  stringOrNull,
  stringOrNullOrUndefined,
  aRealString
);

let aComplexUnion: number | { hello: "world" } | "blue" = {} as any;
const test2$withUnions: typeof aComplexUnion = coalesce(
  stringOrUndefined,
  stringOrNull,
  stringOrNullOrUndefined,
  aComplexUnion
);
// @ts-expect-error - This should fail because test2$withUnions is properly typed!
const test2$result: "red" = test2$withUnions;

const test3$shouldFail: string = coalesce(
  // @ts-expect-error - This should fail because the last argument can't include undefined or null!
  stringOrUndefined,
  stringOrNull
);

Beautiful! Now let’s break this down.

The Breakdown

function coalesce<T>(
  ...args: [...(T | null | undefined)[], T extends null | undefined ? never : T]
): Exclude<T, undefined | null>;

That’s a pretty gnarly ternary, so let’s dive into each part.

function coalesce<T>

T is a generic (like a function argument, but for types). This lets us derive things like other input types or output types from user-supplied arguments! It’s what adds depth to type systems! Here, we’re saying “there’s a generic here. It can be anything. Call it T.”

  ...args

This expression is pure javascript, telling the interpreter, “give me all of the arguments passed into this function as a variable called ‘args’.”

  [...(T | null | undefined)[], T extends null | undefined ? never : T]

This is the complicated part. Let’s start left to right!

 [<other stuff>]

This construction is telling TypeScript, “the ‘args’ argument is a tuple (an array of a fixed length and set of types).

  [...<other stuff>, <other stuff>]

Next, this construction tells TypeScript, “this tuple is made up of an unknown number of elements up front, and a final element.” Now, we can get to the fun parts!

  [...(T | null | undefined)[], <other stuff>]

Here, we’re telling TypeScript, “we don’t really care how many elements are here, or even what type these elements are, so long as they overlap with T, and can also be undefined or null.” In other words, every argument before the last argument is T | undefined | null.

  [...(T | null | undefined)[], T extends null | undefined ? never : T]

Now, all we have left to do is define the last element! After we described the above (every element except the last), the comma is us telling TypeScript, “the last element of this tuple – which, to remind us, is all of the function’s arguments as an array – must be T extends null | undefined ? never : T.

T extends null | undefined ? never : T is a generic ternary expression that in this context tells TypeScript, “the last element of this array is T, but T cannot be undefined or null.” (never in TypeScript is a special token with special powers. In this instance, if TypeScript gets to never in the ternary, it will evaluate as an error.)

And now, for what ties this all together:

): Exclude<T, undefined | null> {

This caps off our function declaration by telling TypeScript, “the return type of this function is T, minus undefined and null.” We add the Exclude clause just to slightly increase the TS evaluation speed of this function.

    return arg as Exclude<T, undefined | null>;
  }
  // Unreachable
  throw new Error(`Unreachable`);

In the WorkTango codebase, we don’t allow type coercion without explicitly calling it out so that your code reviewers can evaluate whether it’s truly necessary. In a scenario like the above, there may be a way to write the function to be fully type safe without an aggressive assertion like this, but any configuration I could come up with that got close to working (none passed all of our type tests) were as painful to try to read and understand as they were to write!

So in those scenarios, we would allow an assertion here for the sake of human readability.

As for the unreachable error, if you followed along above, and read through the code and type security carefully, you will see that the only way this code can be hit is if someone is dabbling around with any script! In our codebase, explicit any is disallowed, so we can be confident that that line of code is never invoked!

Back to the code

Now, back to our original example:

interface Attribute {
  name: string;
  displayName?: string; // <-- Optional
}

/**
 * Given an at tribute, returns its display name if present. Otherwise, returns
 * the attribute's name.
 */
function getAttributeDisplayNameOrName(attribute: Attribute): string {
  return coalesce(attribute.displayName, attribute.name);
}

This function no longer has any logical branches, and is just a single test for us! 💖

it("returns a name!", () => {
  expect(
    getAttributeDisplayNameOrName({ name: "name", displayName: "displayName" })
  ).toEqual("displayName");
});

You could play more code golfing to completely remove the need for this function, if you wanted, but that’s probably not a worthwhile abstraction given the clarity and simplicity of getAttributeDisplayNameOrName, so I’d ship it!