Skip to main content
Hypersphere
Article Cover

Better typings for strings and numbers in TypeString using opaque types

TypeScript is a great tool to ensure your code is correct and you are not passing incorrect data around. Unfortunately, there are still cases when we can’t guarantee that the types are correct — especially when dealing with primitives like strings or numbers.

In almost every modern application we use strings to represent a bunch of different types of data: tokens, resource identifiers, names of events, generic data, template strings, and much more. Each of those use cases is very specific, yet, all of them will usually end up being typed as a generic string.

each of these strings has completely different use-case yet we type them the same

Sometimes it is obvious that one is not interchangeable with another but in other cases it might be tricky without relying on manual testing.

When it comes to numbers, they can represent: currency, virtual points, internal counters, timestamps, etc. Again, in our apps they all are typed as a number.

The infamous example of incorrect use of types that ended up catastrophicaly is Mars Climate Orbiter: one piece of software incorrectly produced results in United States customary units (pound-force seconds) instead of SI units (newton-seconds). It resulted in probe deorbiting on 23 September 1999 and failing $327.6 million mission.

In this article I will deal with slightly less severe examples. I will try to show the drawbacks of using primitive types directly and introduce you to solution: Opaque Types (also commonly referred in TypeScript as Brands).

Opaque Type #

Opaque type is a type which implementation is hidden to the application as a whole, and is accessible only in a specific function or a file. Thanks to that we can ensure the data was created only in a specific place.

Unfortunately due to the duck typing, JavaScript does not really have such mechanism in place. Almost every object can be mimicked by replicating set of its properties. Moreover, nor JavaScript nor TypeScript provide any way of creating distinguishable type aliases (aka. non-structural or nominal typing).

The solution to overcome those limitations is fortunately straight-forward:

The code above will allow us to distinguish between IPs and URLs even though the underlying type in both cases is the same.

Example with two tokens #

Let’s imagine the following scenario. We have a system that uses authentication server to obtain token, which later allows up to communicate with the API. To obtain it, we need to send another token to the auth server. This logic is represented in the diagram below:

Example with two tokens communication

Let’s implement this logic in our code using regular strings:

const authenticateAndGetData = async (token: string) => {
	const apiToken = await getTokenFromAuthServer(token);
	return getData(token);
};

Can you spot the bug in the code above? #

The bug: Instead of using apiToken as an argument to getData method we use the token used to authenticate us with the auth server. In the best case, we would find this error while testing, in the worst, it would end up on production — both scenarios require runtime checks so we cannot use any advantages of the typing system.

Fortunately, there is a solution. We can mark the strings with different “types”, making our code resilient to that kind of errors:

type OpaqueString<T extends string> = string & {
    __type: T;
}

type AuthToken = OpaqueString<"authToken">
type ServiceToken = OpaqueString<"serviceToken">

interface AuthResult {
	token: ServiceToken
};

const getTokenFromAuthServer = (authToken: AuthToken): Promise<AuthResult> => {
	// This is mock implementation, usually you'd make a request to authService here
	return Promise.resolve({
		token: "secret_token" as ServiceToken
	})
};

const getData = (token: ServiceToken) => {
    return { data: "Xxx"}
}

const authenticateAndGetData = async (token: AuthToken) => {
	const apiToken = await getTokenFromAuthServer(token);
	return getData(token);
};

The most interesting part of the code above is an OpaqueString type — it’s “marking” our string with an additional __type field — this field is never meant to be accessed directly (that’s why it is prepended with double underscore). Instead, TypeScript will use it to determine if two types are equivalent to each other. Thanks to that, the code above will result in the following error:

Error in VSCode

Example with currencies #

The same technique can be used to distinguish between different number types. Imagine you are working on an online store and you might have products in different currencies. Summing up the values of products in different currencies does not make any sense and can cause inconsistencies in the system or even a revenue loss. To make sure we’ve implemented all necessary fail-safes in our system, we could use the following opaque type:

type CurrencyCode = "GBP" | "EUR" | "PHP";

type Currency<T extends CurrencyCode> = number & {
    __type: T
};

const computeUKVAT = (price: Currency<"GBP">): Currency<"GBP"> =>
    (price * 0.2) as Currency<"GBP">

const computePhilipinesVAT = (price: Currency<"PHP">): Currency<"PHP"> =>
    (price * 0.12) as Currency<"PHP">;

const priceInGbp = 20 as Currency<"GBP">;
const priceInEur = 30 as Currency<"EUR">;
const priceInPhp = 50 as Currency<"PHP">;


computeUKVAT(priceInGbp);
computeUKVAT(priceInPhp); // type error.

The code above will throw a type error when trying to pass the amount in philippine peso (PHP) to computeUKVat method.

You can also use this method to do more advanced checks like the one below:

type NoUnion<T, U = T> = T extends U ? [U] extends [T] ? T : never : never;

type CurrencyCode = "GBP" | "EUR";

type Currency<T extends CurrencyCode> = number & {
    __type: T
};

const sumProducts = <T extends CurrencyCode>(...products: Currency<NoUnion<T>>[]): Currency<T> => {
    return products.reduce((a, b) => a + b, 0) as Currency<T>;
}

const productInGbp = 20 as Currency<"GBP">;
const productInEur1 = 30 as Currency<"EUR">;
const productInEur2 = 40 as Currency<"EUR">;

sumProducts(productInEur1, productInEur2);
sumProducts(productInGbp, productInEur1);

The code allows you to pass products in a single currency only. The first sumProducts call will not throw any error because all the arguments are in EUR. The second one will fail because its parameters are of different currencies. To detect that we use NoUnion helper type that ensures types do not make a union (they would normally resolve into Currency<”EUR” | “GBP”> which we try to avoid.

More generic solutions #

The problem is not new and there are already existing solutions for it. Many TypeScript utility libraries provide helper generics for it:

If you have used Flow before, you might be familiar with its Opaque Type Aliases which provide similar functionality.

Native TypeScript implementation for similar solution has been discussed in TypeScript community since 2014 but it doesn’t seem to be any consensus so far. Fortunately the solution is straight forward so you can start using it in almost every project if you’d like.