String<Min, Max> - Defining Types with Complex Properties

String<Min, Max> - Defining Types with Complex Properties

An interesting question came up during the Typescript introduction of one of my recent workshops; is it possible to define types that enforce constraints on their underlying primitives? What if, for instance, I want to define a string type that constrains strings to be within a certain range of characters, or match a certain format, or have a valid isbn check sum?

It turns out we can make clever use of phantom types and type guards to create a generator function that returns types which enforce arbitrary constraints.

But let's back up a little. For example to define a type for strings that have a minimum and maximum length, we first need to ensure that we can't assign arbitrary strings to it directly. Typescript's never type does just that, it indicates that a value never occurs, for example a function that contains an event loop and never returns for the duration of the program:

function eventLoop(): never {
    while(true) {
       	//process events
    }
}
let loop = eventLoop();
loop = undefined // Error: Type 'undefined' is not assignable to type 'never'.

By contrast returning void here instead of never would allow a value of null or undefined to be assigned to loop.

We can use this special type to define a type StringOfLength<Min, Max> as an intersection of a string type and a never type. This allows us to treat a value of this type as a string while preventing direct assignment to it:

type StringOfLength<Min, Max> = string & {
	__value__: never
}
const hello: StringOfLength<0, 8> = 'hello' // Type '"hello"' is not assignable to type { __value__: never }

It does not actually matter what name we give to __value__ since it is not directly assignable anyway. What good is a type if I can't use it, you might wonder. It turns out while we can't directly assign a value to a never type we can still cast a value as never and the most common to do so in Typescript is as return value of a type guard function. A type guard is an expression that performs a check at runtime to ensure that a value is of a certain type. A type guard function then takes some value at runtime check whether it meets a certain condition and returns a type predicate, which takes the form parameterName is Type , parameterName being the name of a parameter from the current function signature.

A type guard function for StringOfLength<Min, Max> would consequently check whether a string is within a certain range and return the type predicate str is StringOfLength<Min, Max>:

function isStringOfLength<Min extends number, Max extends number>(
  str: string,
  min: Min,
  max: Max
): str is StringOfLength<Min, Max> => str.length >= min && str.length <= max;

Any time isStringOfLength is called with some string, TypeScript will narrow that variable to StringOfLength<Min, Max> if the original type is compatible.

With this type guard function in hand, we can go ahead and define a simple constructor function for our type:

export function stringOfLength<Min extends number, Max extends number>(
  input: unknown,
  min: Min,
  max: Max
): StringOfLength<Min, Max> => {
  if (typeof input !== "string") {
    throw new Error("invalid input");
  }

  if (!isStringOfLength(input, min, max)) {
    throw new Error(`input string is not between specified min ${min} and max ${max}`);
  }

  return input;
};

const hello = stringOfLength('hello', 1, 8) // hello now has type StringOfLength<1,8>
stringOfLength('buongiorno', 1, 8) // Error: input string is not between specified min 1 and max 8

Using a combination of phantom types, type guards and constructor functions is an elegant and powerful way to construct types with complex properties and encode domain logic in our type system.

Try it yourself on Stackblitz

Addendum

André Kovac alerted me to the fact that using a plain string, a primitive type, in the above example might not be the best way to illustrate the necessity of using never to prevent assignment. Since there also is no string value that would be assignable to a type such as string & { __value__: any }, so it might not be apparent why in general we have to use never instead of any.

So let's look at another example; an object with a property value denoting an ISBN number and a property version indicating whether we are dealing with ISBN-10 or ISBN-13:

type ISBN = {
  value: string;
  version: 'ISBN-13' | 'ISBN-10';
} & {
  __value__: never;
};

We want to ensure that the type cannot be assigned without validating that the value is a valid ISBN, had we intersected the type with {__value__: any }, we could simply assign any object that matches the type structure such as

{
  value: "certainly not an ISBN",
  version: 'ISBN-13',
  __value__: 'bananas'
}

Hence we need to intersect with { __value__: never} to ensure that there exists no value matching our ISBN type structurally.

Writing the type guard and constructor function for this type is left as exercise to the reader!

You can read about how to compute an ISBN check sum here.