TypeScript's type system allows any two types that seem to have the same structure to be assignable to each other. But what if we want to restrict a type to only allow certain values, even if other values happen to have the same structure? Say, marking a difference between sanitized and un-sanitized strings, or positive integers from all numbers?
This need is solvable with a pattern called "branded types", sometimes also called "opaque types". Let's dive into why one might want to use branded types, how to declare and use them, and some alternatives to the pattern.
Branding Needsโ
TypeScript's type system doesn't always provide a way to differentiate types that seem to be structurally the same. For example, some values in application might need to work only with positive numbers. Assigning any number that isn't known to be zero or positive should be a type error:
ts
declare function waitForSeconds(seconds: number): Promise<void>;async function waitThenLog(seconds: number) {// This should not be an error. ๐await waitForSeconds(seconds);// This should be an error: -1 is not positive. ๐await waitForSeconds(-1);console.log("Done!");}
ts
declare function waitForSeconds(seconds: number): Promise<void>;async function waitThenLog(seconds: number) {// This should not be an error. ๐await waitForSeconds(seconds);// This should be an error: -1 is not positive. ๐await waitForSeconds(-1);console.log("Done!");}
TypeScript doesn't have a built-in way to describe positive numbers, but being able to indicate them in the type system could still be useful. In other words, we need a way to "brand" (mark) some number as being not just any old number, but specifically the type we want.
Introducing Branded Typesโ
A "branded type" is one that is the same as an existing type, but with some extra type system property that doesn't actually exist at runtime. That "brand" property is used to differentiate the two types in the type system.
As an example, we could declare a branded type for positive numbers by declaring a Positive
type that is both a number
and an object with a never-used __brand
property.
The values we'll later use as Positive
s won't actually have a __brand
value -- this property is just in the type system.
We can then use that Positive
branded type in place of number
for any location that should only ever receive a positive number:
ts
typePositive = number & {__brand : "positive" };ยdeclare functionwaitForSeconds (seconds :Positive ):Promise <void>;ยasync functionwaitThenLog (seconds :Positive ) {// This should not be an error: seconds are being used. ๐awaitwaitForSeconds (seconds );ย// Now, this errors as we'd expect it to. ๐awaitArgument of type 'number' is not assignable to parameter of type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2345Argument of type 'number' is not assignable to parameter of type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.waitForSeconds (-1);ยconsole .log ("Done!");}
ts
typePositive = number & {__brand : "positive" };ยdeclare functionwaitForSeconds (seconds :Positive ):Promise <void>;ยasync functionwaitThenLog (seconds :Positive ) {// This should not be an error: seconds are being used. ๐awaitwaitForSeconds (seconds );ย// Now, this errors as we'd expect it to. ๐awaitArgument of type 'number' is not assignable to parameter of type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2345Argument of type 'number' is not assignable to parameter of type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.waitForSeconds (-1);ยconsole .log ("Done!");}
By marking our numbers as the Positive
type, we indicated to TypeScript that non-positive numbers shouldn't be used in their place.
In doing so we achieved more safety in our code -- at the cost of writing a few more types.
Branded types are a useful lie to the type system: our positive numbers will never actually have that __brand
property.
We're just making sure that no developer accidentally provides a value of a non-branded type to a location that requires one that is branded.
Per Microsoft/TypeScript#202 Support some non-structural (nominal) type matching, TypeScript doesn't have built-in support for branded types. "Nominal" typing is the term for types that might have similar runtime structures but are treated differently in the type system -- such as branded types.
Branding Valuesโ
The previous code showed passing around values that are already known to be a branded type. But how do we tell the type system that some new value is a branded type?
Creating a new value that is used as a branded type generally requires asserting to TypeScript that the value does, in fact, satisfy the branded type's guarantees.
This is often done in one of two ways: with an as
assertion or with a utility function.
as
Assertionsโ
Type assertions are a way of telling TypeScript something it wouldn't be able to know from code on its own.
In the case of branded values, as
can be used to tell TypeScript that a value is intended to be an instance of a branded type.
The following snippet uses an as
assertion to tell TypeScript that the 123
is, in fact, a Positive
:
ts
typePositive = number & {__brand : "positive" };ยletmyPositive :Positive ;ย// Ok: the assertion tells TypeScript we meant this. ๐myPositive = 123 asPositive ;ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.= 123; myPositive
ts
typePositive = number & {__brand : "positive" };ยletmyPositive :Positive ;ย// Ok: the assertion tells TypeScript we meant this. ๐myPositive = 123 asPositive ;ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.= 123; myPositive
By using an as
assertion, the code told TypeScript that the 123
value should be considered a Positive
.
Notice that no __brand
property was ever assigned at runtime.
The as
assertion told TypeScript to assume it exists.
Using as
assertions for branded types is a convenient way to quickly assert a value to be a branded type.
Unfortunately, as
assertions can bypass type errors in cases where the developer might not be correct.
The previous code could have written something blatantly incorrect, like -1 as Positive
, and TypeScript would have been none the wiser.
Be very careful whenever using as
assertions in TypeScript.
Type Predicatesโ
Instead of writing as
assertions to create new instances of branded types, it's often safer -though more laborious- to use functions that validate values before they can be considered instances of branded types.
"Type predicates" are functions whose return type indicates whether a parameter is
a particular type.
Although they return a boolean
value at runtime, the type system knows to apply type narrowing based on the returned value.
Type predicates can be used to return whether a parameter is
a particular branded type.
This isPositive
returns a boolean
indicating whether value is Positive
.
That means inside of the following if (isPositive(value)) {
, TypeScript knows the value
is of type Positive
:
ts
typePositive = number & {__brand : "positive" };ยfunctionisPositive (value : number):value isPositive {returnvalue > 0;}ยletmyPositive :Positive ;ยletvalue = 123;ยif (isPositive (value )) {// Ok: the type predicate tells TypeScript we meant this. ๐myPositive =value ;}ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.= 123; myPositive
ts
typePositive = number & {__brand : "positive" };ยfunctionisPositive (value : number):value isPositive {returnvalue > 0;}ยletmyPositive :Positive ;ยletvalue = 123;ยif (isPositive (value )) {// Ok: the type predicate tells TypeScript we meant this. ๐myPositive =value ;}ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type '{ __brand: "positive"; }'.= 123; myPositive
Type predicates can be useful for safely gating some logic on whether a value matches a branded type's intent.
But, keep in mind that type predicates aren't much more type-safe than an as
assertion.
TypeScript doesn't check whether the logic of a type predicate matches up to a branded type's intent.
It leaves that up to the code author.
Assertion Functionsโ
Another downside of type predicate functions is that they require writing conditional wrappers in code (the if (isPositive) { ... }
).
Those if
s take up more space and add more runtime logic than as
assertions.
To make our code a little more readable, we can extract a utility function known as a "type assertion function". A type assertion function takes in a value, throws an error if the value doesn't match an expected type, and otherwise returns the value. When paired with a type predicate function, a type assertion function can inform TypeScript's type system that a value is an instance of a branded type.
This asPositive
function uses isPositive
to throw an error if the value isn't an integer.
The result is that calling code can wrap a value with asPositive
to assert that that value is definitely a Positive
:
ts
typePositive = number & {__brand : "positive" };ยdeclare functionwaitForSeconds (seconds :Positive ):Promise <void>;ยfunctionassertPositive (value : number): assertsvalue isPositive {if (value < 0) {throw newError (`${value } is not positive.`);}}ยletn = 123;ย// Ok: the value is positive. ๐assertPositive (n );ย// Ok: the assertion changed the type to be `Positive`. ๐waitForSeconds (n );
ts
typePositive = number & {__brand : "positive" };ยdeclare functionwaitForSeconds (seconds :Positive ):Promise <void>;ยfunctionassertPositive (value : number): assertsvalue isPositive {if (value < 0) {throw newError (`${value } is not positive.`);}}ยletn = 123;ย// Ok: the value is positive. ๐assertPositive (n );ย// Ok: the assertion changed the type to be `Positive`. ๐waitForSeconds (n );
Type assertion functions can be a handy pattern for writing more succinct code with branded types. However, they introduce a new downside of relying on throwing errors to indicate when values aren't the correct type. Thrown errors can't be made type-safe.
As with as
assertions, be very careful whenever using type assertion functions in TypeScript.
Uses for Branded Typesโ
Branded types can be helpful whenever TypeScript should be told that two seemingly-identical structures are actually different. Weโll explore two popular use-cases: branded numbers and branded strings.
Branded Numbersโ
Numbers are some of the most common uses for branded types because TypeScript doesn't support numeric types more specific than bigint
or number
.
Existing proposals such as Microsoft/TypeScript#54925 Numeric Range Types (Feature Update) are still in discussion.
Other numeric variants such as Negative
, NonZero
, and Prime
on can all be represented with branded types.
Currency handling is another useful case for branded numeric types. Applications dealing with multiple currencies might need to make sure numbers used for amounts of one currency don't accidentally get transferred to a different currency.
This code uses a generic Currency<T>
type to set up multiple branded types for currencies.
Attempting to describe a Euro-based gyro's cost in USD results in a type error:
ts
typeCurrency <T > = number & {__currency :T };typeEuro =Currency <"euro">;typeUSD =Currency <"usd">;ยinterfacePurchasableEuro {cost :Euro ;name : string;}ยconstmyPrice = 10 asUSD ;ยconstgyro :PurchasableEuro = {Type 'USD' is not assignable to type 'Euro'. Type 'USD' is not assignable to type '{ __currency: "euro"; }'. Types of property '__currency' are incompatible. Type '"usd"' is not assignable to type '"euro"'.2322Type 'USD' is not assignable to type 'Euro'. Type 'USD' is not assignable to type '{ __currency: "euro"; }'. Types of property '__currency' are incompatible. Type '"usd"' is not assignable to type '"euro"'.: cost myPrice ,name : "euro",};
ts
typeCurrency <T > = number & {__currency :T };typeEuro =Currency <"euro">;typeUSD =Currency <"usd">;ยinterfacePurchasableEuro {cost :Euro ;name : string;}ยconstmyPrice = 10 asUSD ;ยconstgyro :PurchasableEuro = {Type 'USD' is not assignable to type 'Euro'. Type 'USD' is not assignable to type '{ __currency: "euro"; }'. Types of property '__currency' are incompatible. Type '"usd"' is not assignable to type '"euro"'.2322Type 'USD' is not assignable to type 'Euro'. Type 'USD' is not assignable to type '{ __currency: "euro"; }'. Types of property '__currency' are incompatible. Type '"usd"' is not assignable to type '"euro"'.: cost myPrice ,name : "euro",};
Per Microsoft/TypeScript#59423 Don't allow math operations on different branded numeric types, binary operations on branded numeric types don't cause any type errors. Use with caution.
Branded string types can be used whenever numeric values have some characteristic that separates them from other numbers.
Branded Stringsโ
Other uses for branded types include decoded or sanitized text, such as user input that has been sanitized against injected code, passwords, and HTML or URL encoded characters. Branded types can be used to enforce that un-sanitized strings aren't provided in locations that should only use sanitized strings.
This snippet shows protecting against XSS attacks by forcing user input to be sanitized before appending to the DOM:
ts
typeSafeString = string & {__sanitized : true };ย/*** Removes any unsafe parts, such as <script> tags.*/declare functionsanitize (xml : string):SafeString ;ยfunctionwriteToDocument (xml :SafeString ) {document .body .innerHTML +=xml ;}ยconstuserInput = `<script src="evil.js"></script>`;ย// Ok: the text is sanitized and safe for the page ๐writeToDocument (sanitize (userInput ));ย// Type error: unsafe input XML ๐Argument of type 'string' is not assignable to parameter of type 'SafeString'. Type 'string' is not assignable to type '{ __sanitized: true; }'.2345Argument of type 'string' is not assignable to parameter of type 'SafeString'. Type 'string' is not assignable to type '{ __sanitized: true; }'.writeToDocument (); userInput
ts
typeSafeString = string & {__sanitized : true };ย/*** Removes any unsafe parts, such as <script> tags.*/declare functionsanitize (xml : string):SafeString ;ยfunctionwriteToDocument (xml :SafeString ) {document .body .innerHTML +=xml ;}ยconstuserInput = `<script src="evil.js"></script>`;ย// Ok: the text is sanitized and safe for the page ๐writeToDocument (sanitize (userInput ));ย// Type error: unsafe input XML ๐Argument of type 'string' is not assignable to parameter of type 'SafeString'. Type 'string' is not assignable to type '{ __sanitized: true; }'.2345Argument of type 'string' is not assignable to parameter of type 'SafeString'. Type 'string' is not assignable to type '{ __sanitized: true; }'.writeToDocument (); userInput
Writing to innerHTML
is a dangerous practice in general.
Treat this as an educational example, not an endorsement of insecure HTML practices.
Another use for branded string types is the concept of a "guid" (Globally Unique ID) type, used by some applications to assign a unique identifier for every kind of data in their database. GUIDs are typically represented as strings but each data type's GUID can't be used for any other data type. Branded strings can be used to ensure GUID values for different data types aren't accidentally interchanged.
This snippet differentiates comment IDs and post IDs with branded Guid
types:
ts
typeGuid <DataType > = string & {__guid :DataType };ยtypeCommentId =Guid <"comment">;typePostId =Guid <"post">;ยinterfaceComment {id :CommentId ;// ...}ยinterfacePost {id :PostId ;// ...}ยdeclare functiongetCommentById (id :CommentId ):Promise <Comment >;declare functiongetPostById (id :PostId ):Promise <Post >;ยasync functiongetById (id :CommentId ) {// Ok: the branded type matches up ๐awaitgetCommentById (id );ย// Type error: mismatched comment and post ๐awaitArgument of type 'CommentId' is not assignable to parameter of type 'PostId'. Type 'CommentId' is not assignable to type '{ __guid: "post"; }'. Types of property '__guid' are incompatible. Type '"comment"' is not assignable to type '"post"'.2345Argument of type 'CommentId' is not assignable to parameter of type 'PostId'. Type 'CommentId' is not assignable to type '{ __guid: "post"; }'. Types of property '__guid' are incompatible. Type '"comment"' is not assignable to type '"post"'.getPostById (); id }
ts
typeGuid <DataType > = string & {__guid :DataType };ยtypeCommentId =Guid <"comment">;typePostId =Guid <"post">;ยinterfaceComment {id :CommentId ;// ...}ยinterfacePost {id :PostId ;// ...}ยdeclare functiongetCommentById (id :CommentId ):Promise <Comment >;declare functiongetPostById (id :PostId ):Promise <Post >;ยasync functiongetById (id :CommentId ) {// Ok: the branded type matches up ๐awaitgetCommentById (id );ย// Type error: mismatched comment and post ๐awaitArgument of type 'CommentId' is not assignable to parameter of type 'PostId'. Type 'CommentId' is not assignable to type '{ __guid: "post"; }'. Types of property '__guid' are incompatible. Type '"comment"' is not assignable to type '"post"'.2345Argument of type 'CommentId' is not assignable to parameter of type 'PostId'. Type 'CommentId' is not assignable to type '{ __guid: "post"; }'. Types of property '__guid' are incompatible. Type '"comment"' is not assignable to type '"post"'.getPostById (); id }
Branded string types can be useful whenever data from different sources is stored as similar-looking strings, but shouldn't be mixed together.
Community Librariesโ
Branded types aren't used in most TypeScript projects, but they are handy enough that a few community projects have sprung up to make using them easier.
ts-brand
and Effect TS are two of the more popular ones.
ts-brand
โ
The ts-brand
package provides a community-built option to share pre-written code just for type brands.
It exports a generic Brand
type that can be used to create branded types:
ts
import { Brand } from "ts-brand";type Guid = Brand<string, "guid">;const myKnownGuid = "abc123" as Guid;
ts
import { Brand } from "ts-brand";type Guid = Brand<string, "guid">;const myKnownGuid = "abc123" as Guid;
See the ts-brand
API docs for more details.
The next version of ts-brand
will allow make
to take in a validation function.
This article will be updated once it's released.
Effect TSโ
Effect is a popular framework for building type-rich TypeScript applications.
One of the many utilities it provides is a Brand
including:
Brand.Brand
: a generic type that acts as the brand in a typeBrand.nominal
: a generic function that returns a type brand identify function
Put together, the two allow making type brands similar to ts-brand
:
ts
import {Brand } from "effect";ยtypePositive = number &Brand .Brand <"Positive">;ยconstasPositive =Brand .nominal <Positive >();ยletmyPositive :Positive ;ย// Ok: the asPositive tells TypeScript we meant this. ๐myPositive =asPositive (123);ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type 'Brand<"Positive">'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type 'Brand<"Positive">'.= 123; myPositive
ts
import {Brand } from "effect";ยtypePositive = number &Brand .Brand <"Positive">;ยconstasPositive =Brand .nominal <Positive >();ยletmyPositive :Positive ;ย// Ok: the asPositive tells TypeScript we meant this. ๐myPositive =asPositive (123);ย// Type error: TypeScript doesn't know whether this is intended. ๐Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type 'Brand<"Positive">'.2322Type 'number' is not assignable to type 'Positive'. Type 'number' is not assignable to type 'Brand<"Positive">'.= 123; myPositive
Effect
also allows making a type brand assertion function.
Unlike ts-brand
's make
, Effect
provides a separate function, Brand.refined
, that takes in two parameters:
- A function that determines whether a value matches the type brand's constraint
- A function to throw an error for a value that doesn't match the constraint
This asPositive
throws an error if the provided number isn't greater than zero:
ts
import {Brand } from "effect";ยtypePositive = number &Brand .Brand <"Positive">;ยconstasPositive =Brand .refined <Positive >((value ) =>value > 0,(value ) =>Brand .error (`Non-positive value: ${value }`));ย// Ok ๐asPositive (42);ย// Error: Non-positive value: -1 ๐asPositive (-1);
ts
import {Brand } from "effect";ยtypePositive = number &Brand .Brand <"Positive">;ยconstasPositive =Brand .refined <Positive >((value ) =>value > 0,(value ) =>Brand .error (`Non-positive value: ${value }`));ย// Ok ๐asPositive (42);ย// Error: Non-positive value: -1 ๐asPositive (-1);
See Effect's Branded Types docs for more details.
Alternatives to Type Brandsโ
Type brands, as with all type system features, add complexity to code. The benefits of type brands might not outweigh the drawback of that added complexity!
Before using type brands, think on whether they solve any actual issues you're likely to face in your application. You might be better off with a different strategy, such as one of the following ones.
Avoiding Type Brands Altogetherโ
Your type system doesn't have to document every facet of your code. In fact, no type system can fully describe all the nuances of your values. Attempting to do so can sometimes lead to overly complex code or type definitions. Sometimes it's easier to stick with basic type primitives and live with slightly imprecise types.
For example, in an application that operates with numeric values that must always be non-zero, it can be tempting to make a NonZero
branded type to enforce that.
But if values are known by developers to never be zero, it might be easier to just sprinkle in an occasional assertion.
Instead of the following NonZero
type brand and assertion functions with a divide
function...
ts
type NonZero = number & { __brand: "non-zero" };function isNonZero(value: number): value is NonZero {return value !== 0;}function asNonZero(value: number): Positive {if (!isNonZero(value)) {throw new Error("Received unexpected zero.");}return value;}function divide(a: number, b: NonZero) {return asNonZero(a / b);}console.log(divide(12, asNonZero(3)));
ts
type NonZero = number & { __brand: "non-zero" };function isNonZero(value: number): value is NonZero {return value !== 0;}function asNonZero(value: number): Positive {if (!isNonZero(value)) {throw new Error("Received unexpected zero.");}return value;}function divide(a: number, b: NonZero) {return asNonZero(a / b);}console.log(divide(12, asNonZero(3)));
...using a single thrown error inside divide
results in equivalently safe and much more readable code:
ts
functiondivide (a : number,b : number) {if (b === 0) {throw newError ("Cannot divide by zero.");}ยreturna /b ;}ยconsole .log (divide (12, 34));
ts
functiondivide (a : number,b : number) {if (b === 0) {throw newError ("Cannot divide by zero.");}ยreturna /b ;}ยconsole .log (divide (12, 34));
When deciding whether to add branded types to your code, use your best judgement on the tradeoffs. Branded types can help precisely describe code -- at the cost of extra verbosity.
Unionsโ
If a value is known to be one of a limited set of possible types, the best description for it might be a |
union type.
Union types are made to describe a specific set of known allowed types.
As an example, take a Task
class that stores its current status as a status
string.
One version of the class could represent that status
as a branded TaskStatus
type to differentiate it from other strings:
ts
// This should only be: "pending", "rejected", "resolved"typeTaskStatus = string & {__brand : "status" };ยclassTask {#status:TaskStatus = "pending" asTaskStatus ;ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status = "resolved" asTaskStatus )).catch (() => (this.#status = "rejecting" asTaskStatus ));// ~~~~~~~~~~~// This should be "rejected", but there is no type error. ๐}ยgetStatus () {return this.#status;}}
ts
// This should only be: "pending", "rejected", "resolved"typeTaskStatus = string & {__brand : "status" };ยclassTask {#status:TaskStatus = "pending" asTaskStatus ;ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status = "resolved" asTaskStatus )).catch (() => (this.#status = "rejecting" asTaskStatus ));// ~~~~~~~~~~~// This should be "rejected", but there is no type error. ๐}ยgetStatus () {return this.#status;}}
Cases where a value must be one of a set of known types are generally better represented by a union type. Union types are more precise for describing exactly what a value might be.
The TaskStatus
type would be better off as a union of the three known task status strings.
Doing so would have caught the incorrect string in the previous example:
ts
typeTaskStatus = "pending" | "rejected" | "resolved";ยclassTask {#status:TaskStatus = "pending";ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status = "resolved")).catch (() => (this.#status = "rejected"));}ยgetStatus () {return this.#status;}}
ts
typeTaskStatus = "pending" | "rejected" | "resolved";ยclassTask {#status:TaskStatus = "pending";ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status = "resolved")).catch (() => (this.#status = "rejected"));}ยgetStatus () {return this.#status;}}
Whenever a value can only be one of a set of known values, consider using a union type to represent that set.
Enumsโ
Another TypeScript construct for representing a known set of values is an enum. Enums, enumerated type, are similar to unions in that they allow describing a set of related types under one shared name.
Unlike unions, enums are one of the few runtime syntax extensions provided by TypeScript. Each enum declared in code becomes an object. Values under the enum are referred to under the enum's name -- both when used as a type and when as a runtime value.
The TaskStatus
example could be written with an enum TaskStatus
:
ts
enumTaskStatus {Pending = "pending",Rejected = "rejected",Resolved = "resolved",}ยclassTask {#status =TaskStatus .Pending ;ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status =TaskStatus .Resolved )).catch (() => (this.#status =TaskStatus .Rejected ));}ยgetStatus () {return this.#status;}}
ts
enumTaskStatus {Pending = "pending",Rejected = "rejected",Resolved = "resolved",}ยclassTask {#status =TaskStatus .Pending ;ยconstructor(worker : () =>Promise <void>) {worker ().then (() => (this.#status =TaskStatus .Resolved )).catch (() => (this.#status =TaskStatus .Rejected ));}ยgetStatus () {return this.#status;}}
Enums are a bit contentious to some TypeScript developers. They add new runtime code that may eventually conflict with later versions of JavaScript, such as if the TC39 Enums Proposal is ever ratified. On the one hand, they can be useful for having a set of types and values under a shared name.
Template Literal Typesโ
TypeScript allows types describing general patterns of strings called template literal types. These types are handy when a string must match some pattern, but it'd be cumbersome to write out all permutations of that pattern in a union or enum.
The following ThemeStore
class uses a branded string type named Theme
to represent its themes.
However, Theme
doesn't enforce any pattern for the contents, even though the code comment suggests it should be a specific shape of string:
ts
// This should only be two components, joined by a "-":// 1. Base visual: "dark", "light", or "system"// 2. Contrast level: "high", "low", or "standard"typeTheme = string & {__brand : "theme" };ยclassThemeStore {#theme:Theme ;ยconstructor(theme :Theme ) {this.#theme =theme ;}ยgetTheme () {return this.#theme;}ยsetTheme (theme :Theme ) {this.#theme =theme ;}}ยconststore = newThemeStore ("dark-standard" asTheme );ยstore .setTheme ("dark-high" asTheme );ยstore .setTheme ("dark-regular" asTheme );// ~~~~~~~~~~~~~// This should be "dark-standard", but there is no type error. ๐
ts
// This should only be two components, joined by a "-":// 1. Base visual: "dark", "light", or "system"// 2. Contrast level: "high", "low", or "standard"typeTheme = string & {__brand : "theme" };ยclassThemeStore {#theme:Theme ;ยconstructor(theme :Theme ) {this.#theme =theme ;}ยgetTheme () {return this.#theme;}ยsetTheme (theme :Theme ) {this.#theme =theme ;}}ยconststore = newThemeStore ("dark-standard" asTheme );ยstore .setTheme ("dark-high" asTheme );ยstore .setTheme ("dark-regular" asTheme );// ~~~~~~~~~~~~~// This should be "dark-standard", but there is no type error. ๐
Switching Theme
to a template literal string allows TypeScript to give a type error when a string of the wrong pattern is provided:
ts
typeThemeBase = "dark" | "light" | "system";typeThemeContrast = "high" | "low" | "standard";typeTheme = `${ThemeBase }-${ThemeContrast }`;ยclassThemeStore {#theme:Theme ;ยconstructor(theme :Theme ) {this.#theme =theme ;}ยgetTheme () {return this.#theme;}ยsetTheme (theme :Theme ) {this.#theme =theme ;}}ยconststore = newThemeStore ("dark-standard");ยstore .setTheme ("dark-high");ยArgument of type '"dark-regular"' is not assignable to parameter of type '"dark-high" | "dark-low" | "dark-standard" | "light-high" | "light-low" | "light-standard" | "system-high" | "system-low" | "system-standard"'.2345Argument of type '"dark-regular"' is not assignable to parameter of type '"dark-high" | "dark-low" | "dark-standard" | "light-high" | "light-low" | "light-standard" | "system-high" | "system-low" | "system-standard"'.store .setTheme ("dark-regular" );
ts
typeThemeBase = "dark" | "light" | "system";typeThemeContrast = "high" | "low" | "standard";typeTheme = `${ThemeBase }-${ThemeContrast }`;ยclassThemeStore {#theme:Theme ;ยconstructor(theme :Theme ) {this.#theme =theme ;}ยgetTheme () {return this.#theme;}ยsetTheme (theme :Theme ) {this.#theme =theme ;}}ยconststore = newThemeStore ("dark-standard");ยstore .setTheme ("dark-high");ยArgument of type '"dark-regular"' is not assignable to parameter of type '"dark-high" | "dark-low" | "dark-standard" | "light-high" | "light-low" | "light-standard" | "system-high" | "system-low" | "system-standard"'.2345Argument of type '"dark-regular"' is not assignable to parameter of type '"dark-high" | "dark-low" | "dark-standard" | "light-high" | "light-low" | "light-standard" | "system-high" | "system-low" | "system-standard"'.store .setTheme ("dark-regular");
Whenever strings are guaranteed to match a particular format, template literal types can be a precise tool for describing their format in the type system.
Wrappersโ
Instead of using primitive values directly, another strategy shift can be to
For example, this snippet courtesy of Chris Krycho shows a dedicated class with a static of
method for creating and storing validated positive numbers:
ts
class Positive {readonly value: number;private constructor(value: number) {this.value = value;}static of(value: number): Positive | Error {return value > 0? new this(value): new Error(`Non-positive number: ${value}`);}}Positive.of(123); // Positive { value: 123 }Positive.of(-1); // Error { message: "Non-positive number: -1" }
ts
class Positive {readonly value: number;private constructor(value: number) {this.value = value;}static of(value: number): Positive | Error {return value > 0? new this(value): new Error(`Non-positive number: ${value}`);}}Positive.of(123); // Positive { value: 123 }Positive.of(-1); // Error { message: "Non-positive number: -1" }
Wrapper types such as that Positive
class can be very useful when you need the utmost safety in creating and validating numbers.
However, they add overhead -both conceptually and at runtime- for every time they're used.
Consider saving them for only the cases when you absolutely need the utmost safety in your code.
Closing Thoughtsโ
Branded types are a nifty trick in the type system for differentiating structurally identical types from each other. They have quite a few use cases that can help increase program safety.
However, they come with the cost of increased conceptual complexity for working with your code. Whether you're writing your own branded types, onboarding a community library, or opting for an alternative strategy instead, you've got options for using types to precisely describe the characteristics of your values.
Further Readingโ
- Narrowing is covered in Learning TypeScript Chapter 3: Unions and Literals.
- Type predicates are covered in Learning TypeScript Chapter 9: Type Modifiers.
- Enums are covered in Learning TypeScript Chapter 14: Syntax Extensions.
- Template literal types are covered in Learning TypeScript Chapter 15: Type Operations.
Acknowledgementsโ
Many thanks to the kind developers who provided feedback on this article's pull request or published post:
Special thanks in particular to Kenny Lin for invaluable proofreading & suggestions as always.
Much appreciated! โค๏ธ
Got your own TypeScript questions? Tweet @LearningTSBook and the answer might become an article too!