English

Explore TypeScript branded types, a powerful technique for achieving nominal typing in a structural type system. Learn how to enhance type safety and code clarity.

TypeScript Branded Types: Nominal Typing in a Structural System

TypeScript's structural type system offers flexibility but can sometimes lead to unexpected behavior. Branded types provide a way to enforce nominal typing, enhancing type safety and code clarity. This article explores branded types in detail, providing practical examples and best practices for their implementation.

Understanding Structural vs. Nominal Typing

Before diving into branded types, let's clarify the difference between structural and nominal typing.

Structural Typing (Duck Typing)

In a structural type system, two types are considered compatible if they have the same structure (i.e., the same properties with the same types). TypeScript uses structural typing. Consider this example:


interface Point {
  x: number;
  y: number;
}

interface Vector {
  x: number;
  y: number;
}

const point: Point = { x: 10, y: 20 };
const vector: Vector = point; // Valid in TypeScript

console.log(vector.x); // Output: 10

Even though Point and Vector are declared as distinct types, TypeScript allows assigning a Point object to a Vector variable because they share the same structure. This can be convenient, but it can also lead to errors if you need to distinguish between logically different types that happen to have the same shape. For example, thinking of coordinates for latitude/longitude that might incidentally match screen pixel coordinates.

Nominal Typing

In a nominal type system, types are considered compatible only if they have the same name. Even if two types have the same structure, they are treated as distinct if they have different names. Languages like Java and C# use nominal typing.

The Need for Branded Types

TypeScript's structural typing can be problematic when you need to ensure that a value belongs to a specific type, regardless of its structure. For example, consider representing currencies. You might have different types for USD and EUR, but they both could be represented as numbers. Without a mechanism to distinguish them, you could accidentally perform operations on the wrong currency.

Branded types address this issue by allowing you to create distinct types that are structurally similar but treated as different by the type system. This enhances type safety and prevents errors that might otherwise slip through.

Implementing Branded Types in TypeScript

Branded types are implemented using intersection types and a unique symbol or string literal. The idea is to add a "brand" to a type that distinguishes it from other types with the same structure.

Using Symbols (Recommended)

Using symbols for branding is generally preferred because symbols are guaranteed to be unique.


const USD = Symbol('USD');
type USD = number & { readonly [USD]: unique symbol };

const EUR = Symbol('EUR');
type EUR = number & { readonly [EUR]: unique symbol };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// Uncommenting the next line will cause a type error
// const invalidOperation = addUSD(usd1, eur1);

In this example, USD and EUR are branded types based on the number type. The unique symbol ensures that these types are distinct. The createUSD and createEUR functions are used to create values of these types, and the addUSD function only accepts USD values. Attempting to add a EUR value to a USD value will result in a type error.

Using String Literals

You can also use string literals for branding, although this approach is less robust than using symbols because string literals are not guaranteed to be unique.


type USD = number & { readonly __brand: 'USD' };
type EUR = number & { readonly __brand: 'EUR' };

function createUSD(value: number): USD {
  return value as USD;
}

function createEUR(value: number): EUR {
  return value as EUR;
}

function addUSD(a: USD, b: USD): USD {
  return (a + b) as USD;
}

const usd1 = createUSD(10);
const usd2 = createUSD(20);
const eur1 = createEUR(15);

const totalUSD = addUSD(usd1, usd2);
console.log("Total USD:", totalUSD);

// Uncommenting the next line will cause a type error
// const invalidOperation = addUSD(usd1, eur1);

This example achieves the same result as the previous one, but using string literals instead of symbols. While simpler, it's important to ensure that the string literals used for branding are unique within your codebase.

Practical Examples and Use Cases

Branded types can be applied to various scenarios where you need to enforce type safety beyond structural compatibility.

IDs

Consider a system with different types of IDs, such as UserID, ProductID, and OrderID. All these IDs might be represented as numbers or strings, but you want to prevent accidental mixing of different ID types.


const UserIDBrand = Symbol('UserID');
type UserID = string & { readonly [UserIDBrand]: unique symbol };

const ProductIDBrand = Symbol('ProductID');
type ProductID = string & { readonly [ProductIDBrand]: unique symbol };

function getUser(id: UserID): { name: string } {
  // ... fetch user data
  return { name: "Alice" };
}

function getProduct(id: ProductID): { name: string, price: number } {
  // ... fetch product data
  return { name: "Example Product", price: 25 };
}

function createUserID(id: string): UserID {
  return id as UserID;
}

function createProductID(id: string): ProductID {
  return id as ProductID;
}

const userID = createUserID('user123');
const productID = createProductID('product456');

const user = getUser(userID);
const product = getProduct(productID);

console.log("User:", user);
console.log("Product:", product);

// Uncommenting the next line will cause a type error
// const invalidCall = getUser(productID);

This example demonstrates how branded types can prevent passing a ProductID to a function that expects a UserID, enhancing type safety.

Domain-Specific Values

Branded types can also be useful for representing domain-specific values with constraints. For example, you might have a type for percentages that should always be between 0 and 100.


const PercentageBrand = Symbol('Percentage');
type Percentage = number & { readonly [PercentageBrand]: unique symbol };

function createPercentage(value: number): Percentage {
  if (value < 0 || value > 100) {
    throw new Error('Percentage must be between 0 and 100');
  }
  return value as Percentage;
}

function applyDiscount(price: number, discount: Percentage): number {
  return price * (1 - discount / 100);
}

try {
  const discount = createPercentage(20);
  const discountedPrice = applyDiscount(100, discount);
  console.log("Discounted Price:", discountedPrice);

  // Uncommenting the next line will cause an error during runtime
  // const invalidPercentage = createPercentage(120);
} catch (error) {
  console.error(error);
}

This example shows how to enforce a constraint on the value of a branded type during runtime. While the type system cannot guarantee that a Percentage value is always between 0 and 100, the createPercentage function can enforce this constraint during runtime. You may also use libraries like io-ts to enforce runtime validation of branded types.

Date and Time Representations

Working with dates and times can be tricky due to various formats and time zones. Branded types can help differentiate between different date and time representations.


const UTCDateBrand = Symbol('UTCDate');
type UTCDate = string & { readonly [UTCDateBrand]: unique symbol };

const LocalDateBrand = Symbol('LocalDate');
type LocalDate = string & { readonly [LocalDateBrand]: unique symbol };

function createUTCDate(dateString: string): UTCDate {
  // Validate that the date string is in UTC format (e.g., ISO 8601 with Z)
  if (!/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(dateString)) {
    throw new Error('Invalid UTC date format');
  }
  return dateString as UTCDate;
}

function createLocalDate(dateString: string): LocalDate {
  // Validate that the date string is in local date format (e.g., YYYY-MM-DD)
  if (!/\d{4}-\d{2}-\d{2}/.test(dateString)) {
    throw new Error('Invalid local date format');
  }
  return dateString as LocalDate;
}

function convertUTCDateToLocalDate(utcDate: UTCDate): LocalDate {
  // Perform time zone conversion
  const date = new Date(utcDate);
  const localDateString = date.toLocaleDateString();
  return createLocalDate(localDateString);
}

try {
  const utcDate = createUTCDate('2024-01-20T10:00:00.000Z');
  const localDate = convertUTCDateToLocalDate(utcDate);
  console.log("UTC Date:", utcDate);
  console.log("Local Date:", localDate);
} catch (error) {
  console.error(error);
}

This example differentiates between UTC and local dates, ensuring that you're working with the correct date and time representation in different parts of your application. Runtime validation ensures that only correctly formatted date strings can be assigned these types.

Best Practices for Using Branded Types

To effectively use branded types in TypeScript, consider the following best practices:

Advantages of Branded Types

Disadvantages of Branded Types

Alternatives to Branded Types

While branded types are a powerful technique for achieving nominal typing in TypeScript, there are alternative approaches that you might consider.

Opaque Types

Opaque types are similar to branded types but provide a more explicit way to hide the underlying type. TypeScript doesn't have built-in support for opaque types, but you can simulate them using modules and private symbols.

Classes

Using classes can provide a more object-oriented approach to defining distinct types. While classes are structurally typed in TypeScript, they offer a clearer separation of concerns and can be used to enforce constraints through methods.

Libraries like `io-ts` or `zod`

These libraries provide sophisticated runtime type validation and can be combined with branded types to ensure both compile-time and runtime safety.

Conclusion

TypeScript branded types are a valuable tool for enhancing type safety and code clarity in a structural type system. By adding a "brand" to a type, you can enforce nominal typing and prevent accidental mixing of structurally similar but logically different types. While branded types introduce some complexity and overhead, the benefits of improved type safety and code maintainability often outweigh the drawbacks. Consider using branded types in scenarios where you need to ensure that a value belongs to a specific type, regardless of its structure.

By understanding the principles behind structural and nominal typing, and by applying the best practices outlined in this article, you can effectively leverage branded types to write more robust and maintainable TypeScript code. From representing currencies and IDs to enforcing domain-specific constraints, branded types provide a flexible and powerful mechanism for enhancing type safety in your projects.

As you work with TypeScript, explore the various techniques and libraries available for type validation and enforcement. Consider using branded types in conjunction with runtime validation libraries like io-ts or zod to achieve a comprehensive approach to type safety.