Hone logo
Hone
Problems

Building Nominal Typing in TypeScript

TypeScript's structural typing system can sometimes lead to unexpected behavior when differentiating between types that have the same structure but represent different concepts. This challenge asks you to implement a mechanism that simulates nominal typing, allowing you to create distinct types that are not assignable to each other, even if their underlying structures are identical. This is crucial for creating more robust and type-safe applications by preventing accidental misuse of similar data structures.

Problem Description

Your task is to create a system in TypeScript that allows for the creation of nominal types. A nominal type system means that types are distinguished by their names (or a unique identifier) rather than their structure. In TypeScript, you should achieve this by:

  1. Creating a Type Constructor: Design a generic type constructor (e.g., Nominal<T, K>) that takes an underlying primitive or object type T and a unique brand K.
  2. Enforcing Uniqueness: Ensure that two Nominal types created with different brands are not assignable to each other, even if their underlying T is the same.
  3. Type Safety: Allow operations on the underlying type T through a safe casting or accessor mechanism, without exposing the internal branding.

Key Requirements:

  • Define a generic Nominal<T, K> type. T represents the base type (e.g., string, number, object), and K is a unique "brand" or identifier to make the type nominal.
  • Implement a way to create new nominal types from the Nominal constructor (e.g., a helper function or a specific pattern).
  • Ensure that Nominal<T, BrandA> is not assignable to Nominal<T, BrandB> if BrandA and BrandB are different.
  • Provide a mechanism to safely access the underlying value of a nominal type (e.g., get<T, K>(nominalValue: Nominal<T, K>): T). This is important to avoid needing to cast everywhere.

Expected Behavior:

  • A variable of type Nominal<string, 'UserId'> should not be directly assignable to a variable of type Nominal<string, 'ProductId'>.
  • Operations on the underlying type should be possible after safely extracting the value.

Edge Cases to Consider:

  • What happens if the brand K is not unique? (Your system should implicitly handle this by making distinct brand types).
  • How to handle nominal types for primitive types vs. object types.

Examples

Example 1: Creating and Assigning String-Based Nominal Types

// Assume Nominal and createNominal are defined as per the challenge.

type UserId = Nominal<string, "UserId">;
type ProductId = Nominal<string, "ProductId">;

// Helper function to create nominal types (implementation details hidden for example)
function createNominal<T, K extends string>(value: T, brand: K): Nominal<T, K> {
  // ... internal implementation to create a distinct type
  return value as any; // Placeholder for demonstration
}

// Creating instances
const userId: UserId = createNominal("user-123", "UserId");
const productId: ProductId = createNominal("prod-abc", "ProductId");

// Attempting to assign: This should cause a TypeScript error.
// let anotherUserId: UserId = productId; // Error expected here
// let someString: string = userId; // Error expected here if direct assignment is prevented

// Safely accessing the underlying value:
const userIdString: string = get<string, "UserId">(userId); // Should return "user-123"
const productIdString: string = get<string, "ProductId">(productId); // Should return "prod-abc"

console.log(userIdString); // Output: user-123
console.log(productIdString); // Output: prod-abc

Explanation:

The UserId and ProductId types are created using the Nominal constructor with different string literals as brands. Even though both are based on string, they are treated as distinct types by the nominal typing system. Assigning a ProductId to a UserId variable (or vice versa) would result in a compile-time error. The get function allows us to safely retrieve the underlying string value.

Example 2: Creating and Assigning Object-Based Nominal Types

// Assume Nominal and createNominal are defined.

interface User {
  name: string;
  age: number;
}

interface Product {
  name: string;
  price: number;
}

type UserProfile = Nominal<User, "UserProfile">;
type ProductDetails = Nominal<Product, "ProductDetails">;

// Creating instances
const user: UserProfile = createNominal({ name: "Alice", age: 30 }, "UserProfile");
const product: ProductDetails = createNominal({ name: "Laptop", price: 1200 }, "ProductDetails");

// Attempting to assign: This should cause a TypeScript error.
// let anotherUser: UserProfile = product; // Error expected here

// Safely accessing the underlying value:
const userObj: User = get<User, "UserProfile">(user); // Should return { name: "Alice", age: 30 }
const productObj: Product = get<Product, "ProductDetails">(product); // Should return { name: "Laptop", price: 1200 }

console.log(userObj.name); // Output: Alice
console.log(productObj.price); // Output: 1200

Explanation:

Similar to the string example, UserProfile and ProductDetails are nominal types based on different object interfaces. They are not interchangeable, even if they share common properties like name. The get function allows us to access the original User or Product object.

Example 3: Demonstrating No Assignability Between Different Brands

// Assume Nominal and createNominal are defined.

type EmailAddress = Nominal<string, "EmailAddress">;
type Username = Nominal<string, "Username">;

const userEmail: EmailAddress = createNominal("test@example.com", "EmailAddress");
const systemUsername: Username = createNominal("admin", "Username");

// These assignments should all fail at compile time:
// let badEmailAssign: EmailAddress = systemUsername;
// let badUsernameAssign: Username = userEmail;
// let explicitStringAssign: string = userEmail;

Explanation:

This example explicitly shows that even though EmailAddress and Username are both nominal types built on string, they are entirely distinct. Attempts to assign one to the other, or to a plain string, will be caught by the TypeScript compiler, enforcing true nominal typing.

Constraints

  • The Nominal type constructor should be a generic type accepting at least two type parameters: the base type T and the brand K.
  • The branding mechanism should rely on TypeScript's type system and not runtime checks (unless absolutely necessary for a fallback or specific accessors).
  • The solution should aim for minimal runtime overhead. The primary goal is compile-time type safety.
  • The solution should not rely on external libraries.

Notes

  • Consider using a pattern where the brand K is a unique type (e.g., a branded string literal type or a unique symbol) to ensure distinctness.
  • Think about how you will implement the createNominal function (or equivalent) to correctly construct these nominal types and ensure they are distinct at the type level. A common approach involves using intersection types or opaque types.
  • The get function will likely involve a type assertion, but it should be used within a well-defined interface to maintain safety. The goal is to make the interface of nominal types safe, not necessarily to eliminate all assertions in the implementation of the system itself.
Loading editor...
typescript