TypeScript: from Stringly Typed to Strongly Typed

03 April, 202410 min read
A black background with three white sqiggle outlines each with decreasing opacity. Depicting strings from string theory

TypeScript is often referred to as being "strongly typed", usually based on the fact that it is a typed language containing primitives like string, number, and boolean. Here I will explain why this idea is wrong, and largely speaking why most TypeScript codebases are actually "stringly typed". We'll take a look at why it's not ideal to model all text based values as a string, examples of where this can cause problems in your domain model and functions, and finally look at our options for making our code strongly typed.

TL;DR if you're already familiar with "stringly typed" codebases feel free to jump to the solution.

What is Stringly Typed?

A string is a primitive data type used to represent text data. It's often overused for all different types of text, which can lead to "stringly typed" code — where variables are only distinguished by the values they hold rather than their type.

Overusing strings in this way can lead to many issues. It can make it difficult to know the expected format or content of a variable. Is this string a URL? An Email? A Password? It's not clear from the type.

Let's take typing as whole to its extreme. If we're happy to store all text as strings regardless of their type. Why stop there? Why not represent all types as a byte array instead? The reason we don't do this is because we'd end up with a dynamic language, meaning everything is a runtime error rather than compile time.

This over-usage of the string type undermines being in a static language and having compile time checks. So how do we make a stronger string type? One that allows the compiler to distinguish between different types of textual data.

Domain modelling with primitive types

Let's look at an example of when you might currently use a string, by modelling a simple User type.

type User = {
  firstName: string;
  lastName: string;
  email: string;
};
type User = {
  firstName: string;
  lastName: string;
  email: string;
};

This looks typical of a TypeScript project right? Let's start trying to work with this model, we need a function that can convert a user's name to their initials. i.e. Matt PhillipsMP.

const getInitials = (firstName: string, lastName: string): string => {
  return `${firstName.charAt(0)}${lastName.charAt(0)}`;
};
const getInitials = (firstName: string, lastName: string): string => {
  return `${firstName.charAt(0)}${lastName.charAt(0)}`;
};

Now let's use our getInitials function and see why string isn't the best type option here.

const user: User = {
  firstName: 'Matt',
  lastName: 'Phillips',
  email: 'hello@example.com'
};
 
const initials = getInitials(user.lastName, user.firstName);
//    ^ "PM"
const user: User = {
  firstName: 'Matt',
  lastName: 'Phillips',
  email: 'hello@example.com'
};
 
const initials = getInitials(user.lastName, user.firstName);
//    ^ "PM"
🐛Looks like we might have a bug!

The initials we have created for our user are PM instead of MP and TypeScript is happy as the types are all correct.

How might we fix this sort of problem? We could instead pass the whole User to getInitials so that it can index on the firstName and lastName keys. But now we are exposing all properties of a user to a function that only cares about the first and last names. If we want to write a unit test for this function, we need to generate unnecessary data that's never used.

This solution also ignores the fact that TypeScript is happy to compile this code because it thinks that a firstName is equal to a lastName (or even an email!). This means the compiler can never catch accidental usage of a value in the wrong context.

FirstName !== LastName

Not all text based data should be the same type, and in this case a user's first name is not the same type as their last name. How do we make it so that TypeScript treats the first and last name differently? Let's try a few options:

Type Aliases

What about if we use a type alias to create a FirstName and LastName type?

type FirstName = string;
type LastName = string;
 
type Equals<A, B> = A extends B ? true : false;
 
type Test = Equals<FirstName, LastName>;
//   ^ true
type FirstName = string;
type LastName = string;
 
type Equals<A, B> = A extends B ? true : false;
 
type Test = Equals<FirstName, LastName>;
//   ^ true

Here we can see that type aliases are what they say on the tin, aliases. They are a way to assign a different name to a type but it is still the underlying type.

Class / object boxing

What if we wrap a string inside of a class / object?

class FirstName {
  constructor(public value: string) {}
}
 
class LastName {
  constructor(public value: string) {}
}
 
type Test = Equals<FirstName, LastName>;
//   ^ true
class FirstName {
  constructor(public value: string) {}
}
 
class LastName {
  constructor(public value: string) {}
}
 
type Test = Equals<FirstName, LastName>;
//   ^ true

With TypeScript being structurally typed, FirstName and LastName are still equal in shape. We could change their structure to not be identical. But this doesn't prevent us from re-creating the same structure elsewhere, bringing us back to the original problem.

This encoding also comes with the problem of adding a small overhead of creating an object anywhere we want a string. Any code using this object now needs to call .value to access the string.

💡What is structural typing?

Structural typing is a way of relating types based on their properties. This means that TypeScript only takes into account the properties of the type for equality.

For example: if the type is shaped like a pet, then it's a pet. If a cat has at least all properties of a pet, then it's also a pet.

Solution

Given TypeScript is structurally typed we need a way to make it support nominal types. How do we make it so that our firstName and lastName have different nominal types? If we look at other languages we will see techniques for this exact problem. The solution has different names depending on the language. To name a couple: Haskell's newtype or Flow's opaque type. These are nominal type aliases that do not allow access to their underlying type.

💡What is nominal typing?

Within a type system it's how types are uniquely related based on their declaration and / or name. This means that even if two types have the exact same properties, they'd be considered different.

For example: if the nominal type is shaped like a pet, then it is a pet. If a cat has at least all properties of a pet, then it is not a pet but only a cat.

Ideally we want the best of both worlds so that we can:

  • Operate over our nominal types as if they were the underlying type to access properties on them
  • Get compile time errors that verify nominal types of the same underlying type to be different.

The solution is to brand our primitives using an intersection (&) with a unique symbol. In TypeScript each unique symbol has a completely separate identity. We can lean on this uniqueness to brand our primitives to make them nominally typed.

Let's take a look at what this might look like:

type FirstName = string & { readonly unique symbol: FirstName };
type LastName = string & { readonly unique symbol: LastName };
 
type Test = Equals<FirstName, LastName>;
//   ^ false 🎉🎉🎉🎉🎉
type FirstName = string & { readonly unique symbol: FirstName };
type LastName = string & { readonly unique symbol: LastName };
 
type Test = Equals<FirstName, LastName>;
//   ^ false 🎉🎉🎉🎉🎉
🎉Success! We have nominal types

We've finally done it, our FirstName and LastName are different types! The beauty of this solution is that we can have two unique symbols with the same description and they'll still be completely unique.

We can still access the properties as if they are the underlying type. This means there is no performance overhead to consider. This technique can work for any type too, not just for strings.

Now I'm sure you're wondering this is great and all, but how do I construct a value of a nominal type? Let's build ourselves a Nominal type constructor. It should allow us to:

  • Define a primitive as a nominal type based on some unique symbol.
  • Give us a function to construct an instance of our nominal type from its underlying type.
type Nominal<A, Brand extends symbol> = A & {
  __brand: Brand;
  __original: A;
};
 
const Nominal =
  <N extends Nominal<unknown, symbol>>() =>
  (a: N['__original']): N =>
    a as N;
type Nominal<A, Brand extends symbol> = A & {
  __brand: Brand;
  __original: A;
};
 
const Nominal =
  <N extends Nominal<unknown, symbol>>() =>
  (a: N['__original']): N =>
    a as N;

Don't be alarmed by this definition, it's actually pretty simple. We have created a generic Nominal type that intersects our underlying type with a branding object.

This branding object is a place for us to do two things:

  • To store the symbol identifier which which will provide us with uniqueness. This is what will allow TypeScript to type check for nominal differences.
  • To store the underlying type, so that we can then use this to create a generic constructor function. This function will lookup the underlying type so that the compiler knows which type to expect when creating a nominal value.

Domain modelling with nominal types

Here's how we can re-write our User type.

type FirstName = Nominal<string, { readonly FirstName: unique symbol }['FirstName']>;
const FirstName = Nominal<FirstName>();
 
type LastName = Nominal<string, { readonly LastName: unique symbol }['LastName']>;
const LastName = Nominal<LastName>();
 
type Email = Nominal<string, { readonly Email: unique symbol }['Email']>;
const Email = Nominal<Email>();
 
type User = {
  firstName: FirstName;
  lastName: LastName;
  email: Email;
};
type FirstName = Nominal<string, { readonly FirstName: unique symbol }['FirstName']>;
const FirstName = Nominal<FirstName>();
 
type LastName = Nominal<string, { readonly LastName: unique symbol }['LastName']>;
const LastName = Nominal<LastName>();
 
type Email = Nominal<string, { readonly Email: unique symbol }['Email']>;
const Email = Nominal<Email>();
 
type User = {
  firstName: FirstName;
  lastName: LastName;
  email: Email;
};

Now the type of each variable indicates its intended use. Assigning a value of the wrong type will result in a compile time error. Let's re-write our getInitials function using our nominal types.

const user: User = {
  firstName: FirstName('Matt'),
  lastName: LastName('Phillips'),
  email: Email('hello@example.com')
};
 
const getInitials = (firstName: FirstName, lastName: LastName): string => {
  return `${firstName.charAt(0)}${lastName.charAt(0)}`;
};
 
const initials = getInitials(user.lastName, user.firstName);
//    ^ Compile Error: Argument of type 'LastName' is not assignable to parameter of type 'FirstName'.
const user: User = {
  firstName: FirstName('Matt'),
  lastName: LastName('Phillips'),
  email: Email('hello@example.com')
};
 
const getInitials = (firstName: FirstName, lastName: LastName): string => {
  return `${firstName.charAt(0)}${lastName.charAt(0)}`;
};
 
const initials = getInitials(user.lastName, user.firstName);
//    ^ Compile Error: Argument of type 'LastName' is not assignable to parameter of type 'FirstName'.
Compile errors FTW

initials is no longer compiling! Instead TypeScript can see that our types are different so the parameters can no longer be mixed up.

Open Source Solutions

We've built a Nominal type to give us a deeper understanding of what it is doing and how it works. But ideally we wouldn't write Nominal ourselves and copy it to / from each project. And thankfully we don't need to! We can instead lean on open-source solutions. In particular Effect's Nominal Branded Type. It provides exactly what we need, without maintaining the definition ourselves.

It has a slightly different syntax requiring us to write the intersection ourselves. It also has the ability to brand primitives with a string literal as well as a unique symbol. It's extremely close to our encoding and well worth trying out.

import { Brand } from 'effect';
 
// string brand
type FirstName = string & Brand.Brand<'FirstName'>;
const FirstName = Brand.nominal<FirstName>();
 
// symbol brand
type LastName = string & Brand.Brand<{ readonly LastName: unique symbol }['LastName']>;
const LastName = Brand.nominal<LastName>();
 
// usage
const firstName = FirstName('Matt');
const lastName = LastName('Phillips');
import { Brand } from 'effect';
 
// string brand
type FirstName = string & Brand.Brand<'FirstName'>;
const FirstName = Brand.nominal<FirstName>();
 
// symbol brand
type LastName = string & Brand.Brand<{ readonly LastName: unique symbol }['LastName']>;
const LastName = Brand.nominal<LastName>();
 
// usage
const firstName = FirstName('Matt');
const lastName = LastName('Phillips');

Closing thoughts

In this article we've examined what a string is, why it's not the best type for your domain model and, how we can make our code strongly typed with nominals. They're easier to understand, serving as documentation for other developers on the expected use of each variable. They will help catch accidental bugs at compile time.

It's worth reiterating that this article can be applied to all primitives. I have focussed on strings because they are most commonly overused.

Of course, nominals are not a silver bullet either. As with everything there isn't a right and wrong way to do things, and using nominal values is no different. You might not need to use a nominal value everywhere that you may use a primitive. With that said at the very least I would recommend using them for your core domain models.

In the next article we'll build on top of this to make our types even stronger using refinement types.

Matt Phillips

Matt Phillips

Software engineer and founder from the UK with a passion for teaching all things software related, career development and building products.

Want to keep up to date with everything I'm working on? Then follow me over on Twitter.

If you've enjoyed this article, please consider sponsoring me on GitHub

Sponsor

Related posts that you may also enjoy