A less obvious nominal typing use case

Surprised

Nominal type systems, unlike structural ones, are concerned with the actual types of values, not their structure. Assignability is resolved via type checks, not with "yep, it's good enough" checks. Sounds reasonable, doesn't it? In the TypeScript universe, however, nominal typing is often dismissed as non-idiomatic and obscure. In reality, though, when faced with a problem that requires choosing between type safety and the lack thereof, or between simplicity and something unnaturally complex, nominal typing is often the answer. Yes, that is a bold claim, but in this article, I'll do my best to justify it.


ℹ️ Using the term "nominal typing" in the context of TypeScript can, of course, be considered a bit of a stretch. While its type system is structural, techniques exist that let one emulate the behaviour of a nominal one.


Surprise! We'll start by stating the problem:

An overly wide type pollutes a beautifully narrow one.

If that doesn't ring a bell, think of merging two sets of values: those you "own" and know the structure of, and "external" ones, whose structure can be arbitrary. The uncertainty of the latter can spill over into the former, and it's highly likely you don't want that. Enough talk - let's jump to examples.

Example: colors

Suppose you need to store a color value. It could be anything, such as a base color swatch to compute the entire theme; that doesn’t matter. What does matter is storing the color in a type-safe way. Since you’re likely to set that value frequently, you’d want to avoid typos, right? Fortunately, TypeScript has got your back... or does it?

type Color = 'red' | 'green' | 'blue';
 
declare const setColor: (newColor: Color) => void;
 
setColor('green');
setColor('potato'); // 💥

View source

So far, so good, but let's not forget why we're here. A new requirement has arrived: in addition to selecting from a predefined list of colors, allow arbitrary ones, either through a color picker or some external source. What do you do?

type CustomColor = string;
type Color = 'red' | 'green' | 'blue' | CustomColor;
 
declare const setColor: (newColor: Color) => void;
 
setColor('green');
setColor('potato'); // Also fine 😱

View source

What happened there? When trying to reconcile a string with 'red' | 'green' | 'blue', TypeScript widened both types to their closest common ancestor and inferred the result as a string. While this behavior is expected, it completely ruins type safety in this particular case.

Example: tabs

A somewhat similar scenario. In your app, there are tabs. Besides user interaction, you also allow programmatic navigation. The order of tabs isn't fixed either, so using indices is not an option. Let's assume the app is built using React, and the tabs' behavior is encapsulated in a hook. It could look something like this:

type UseTabsConfig<T extends string> = {
  tabNames: ReadonlyArray<T>;
  initial: NoInfer<T>;
};
 
type Tabs<T extends string> = ReadonlyArray<{
  tabName: T;
  active: boolean;
}>;
 
type Setter<T extends string> = (tabName: T) => void;
 
declare const useTabs: <T extends string>(config: UseTabsConfig<T>) => [Tabs<T>, Setter<T>];

View source

Now you implement tabs like a boss:

const tabNames = ['tab1', 'tab2', 'tab3'] as const;
 
function MyComponent() {
  const [, setTab] = useTabs({
    tabNames,
    initial: 'tab1'
  });
 
  return (
    <button onClick={() => {
      setTab('tab1');
    }}>
      Tab1
    </button>
  );
}

View source

Just like before, everything falls apart once we throw some uncertainty into the mix. Alongside the "well-known" tabs, let's try rendering some dynamic ones. Uncharted territory turns out to be a bit less friendly:

declare const remoteTabNames: string[];
const tabNames = ['tab1', 'tab2', 'tab3', ...remoteTabNames] as const;
 
function MyApp() {
  const [, setTab] = useTabs({
    tabNames,
    initial: 'potato' // Oh no 😱
  });
 
  return (
    <button onClick={() => {
      setTab('eggplant'); // Oh no 😱
    }}>
      Tab1
    </button>
  );
}

View source

And once again, type safety is gone in the blink of an eye. While this is somewhat acceptable for the "remote" tabs, there are others we have control over, and we would at least like to be able to safely navigate between them.

Okay, that is all pretty depressing, but what are...

The options

First of all, chances are that the code still works, provided, of course, you haven't misspelled a color, tab name, or anything else. While the type system force field is severely weakened, one might argue it's not business-critical, and that's fine. For those who are not easily scared, doing nothing might actually be a good enough solution. Everyone else will likely find it repelling.

Another approach involves somehow narrowing down the excessively wide type. Depending on the context, this may or may not be a good idea, as I shall now demonstrate. Let's start with the colors example. As an appetizer, we serve a single character from a hex color value:

type Digit = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
type Letter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f';
type Char = Digit | Letter | Capitalize<Letter>;

View source

So far, so good. Now, to the syntax. According to MDN, a valid hex color value is comprised of either 3, 4, 6, or 8 characters (prefixed with #). One might think it's a good idea to simply "glue" characters together (thus producing a union type representing a valid hex color) using template literal types, but there's one subtlety that will prevent them from doing so: the sheer complexity of such an operation. A single character can have 22 different values (10 digits + 6 lowercase letters + 6 uppercase letters). An 8-character hex color can then have 22 to the power of 8 (54,875,873,536) different values. That is a lot for the TypeScript compiler to unpack, thus we're greeted with this lovely message:

Expression produces a union type that is too complex to represent.


ℹ️ The version of TypeScript available at the time of writing is 5.5.3.


type Short = `#${Char}${Char}${Char}`; // 👍
type ShortAlpha = `#${Char}${Char}${Char}${Char}`; // 💥

View source

Even if TypeScript were able to properly build the union type, it would neither be readable nor worth slowing down compilation. Things get even worse when we consider the second example - tabs. There's no constraint we can place on the value, thus it will forever remain a string, making it virtually impossible to represent as a union type.

A different approach trades compile-time complexity for runtime complexity. Instead of passing around raw values, we now use structures that also carry some metadata. This metadata comes in the form of an extra property denoting the type of the value.

type Color =
  | { type: 'well-known'; value: 'red' | 'green' | 'blue'; }
  | { type: 'custom'; value: string; };
 
declare const setColor: (newColor: Color) => void;
 
/* 1 */ setColor({ type: 'well-known', value: 'green' });  
/* 2 */ setColor({ type: 'well-known', value: 'potato' });
/* 3 */ setColor({ type: 'custom', value: '#0f0' });
/* 4 */ setColor({ type: 'custom', value: 'potato' });

View source

Okay, what have we achieved?

  1. First of all, we no longer mix string with literal types, so no type information is lost.
  2. Courtesy of discriminated unions, 'potato' is immediately reported as an invalid value for the discriminator 'well-known'. Everything is in order.
  3. Once type is set to 'custom', we are free to use hex colors.
  4. However, we’re also free to use everything else, which isn’t convenient. We’d need to add some data validation into the mix and be diligent enough to use only validated values, which might be harder than it sounds.

There’s one more potential problem that will become more apparent in contexts where referential equality matters. Remember the tabs example? By moving from primitive types to the descendants of Object.prototype, we have taken on the responsibility for unwanted side effects, so now we might want to use memoization. All these small things add up.

If only there was...

A better way

Well, there is, and the foreword of this article might have even given you a hint about what it is. Without further ado, let me introduce you to:

declare const __brand: unique symbol;
 
type Brand<T, B extends string> = T & { [__brand]: B };

View source

Why use a branded type? The reason is simple: it not only keeps the implementation overhead minimal but also ticks all the boxes. What do I mean?

  • No need to modify the existing data.
  • No need to narrow down the type of remote data; in fact, we no longer even need to care because...
  • The remote data type now becomes opaque; its exact shape is irrelevant.
  • We can still easily validate the data (and filter out invalid bits) if the data source is not trustworthy.

Let's apply this to our examples. First, colors:

declare const __brand: unique symbol;
 
type Brand<T, B extends string> = T & { [__brand]: B };
 
type CustomColor = Brand<string, 'CustomColor'>;
type Color = 'red' | 'green' | 'blue' | CustomColor;
 
declare const setColor: (newColor: Color) => void;
 
setColor('green');
setColor('potato'); // No longer valid

View source

And what about the color picker? The only thing we need to do is validate the value (or not, if we trust the source) and store the validation result within the type system:

// Validate the color
declare const isColor: (candidate: string) => candidate is CustomColor;
 
const myColor = '#f00';
 
setColor(myColor); // Nope
if (isColor(myColor)) setColor(myColor); // 👍

View source

In a similar fashion, we "fix" the tabs:

type RemoteTab = Brand<string, 'RemoteTab'>;
 
declare const remoteTabNames: RemoteTab[];
const tabNames = ['tab1', 'tab2', 'tab3', ...remoteTabNames] as const;
 
function MyApp() {
  const [, setTab] = useTabs({
    tabNames,
    initial: 'potato' // No longer valid
  });
 
  return (
    <button onClick={() => {
      setTab('eggplant'); // No longer valid
    }}>
      Tab1
    </button>
  );
}

View source

That's all, folks!

Photo by Heather Wilde on Unsplash.