Introduction to TypeScript’s Structural Typing and Its Limitations
TypeScript employs a structural type system, where type compatibility is determined by the shape or structure of a type rather than its explicit declaration. This means that if two types have the same structure, TypeScript considers them compatible, regardless of their names or intended semantics. While this approach offers flexibility and works well for many JavaScript patterns, it creates room for a specific class of bugs where logically distinct types become interchangeable simply because they share the same underlying structure. Consider a common scenario where both user IDs and order IDs are represented as strings: despite having different semantic meanings in the business domain, TypeScript treats them as identical types, allowing accidental assignments that can lead to subtle bugs .
The need for nominal typing techniques becomes apparent when examining real-world type safety requirements. In financial applications, you wouldn’t want to accidentally add euros to dollars without conversion. When dealing with user input, you’d want to distinguish between validated and unvalidated strings to prevent security issues. For physical calculations, adding meters to kilometers without conversion would yield incorrect results. These are all cases where the semantic meaning of the data is more important than its structural representation, creating a gap in TypeScript’s native type safety guarantees .
This article explores practical techniques to simulate nominal typing within TypeScript’s structural type system. Through methods like type branding, opaque types, and specialized class patterns, developers can create type distinctions that prevent accidental misuse of semantically different data. These approaches allow TypeScript developers to enjoy the flexibility of structural typing when appropriate while enforcing nominal type safety in critical code paths where logical errors could have significant consequences . By implementing these patterns, you can create self-documenting code that captures important domain semantics at the type level, moving beyond basic type checking to create more robust and maintainable applications.
Understanding Nominal Typing: Concepts and Benefits
Structural vs. Nominal Typing: Key Differences
Structural typing, which TypeScript uses by default, focuses on the shape or structure of types. Two types are considered compatible if they have the same properties and methods, regardless of their names or where they were defined. This approach is flexible and works well with JavaScript’s dynamic nature. However, it can lead to situations where types with different semantic meanings become interchangeable simply because they share the same structure. For example, TypeScript would consider a Student type with a name property compatible with a Teacher type that also has a name property, even though they represent entirely different domain concepts .
In contrast, nominal typing (used in languages like Java and C#) determines type compatibility based on explicit type declarations and inheritance hierarchies. Two types are compatible only if one explicitly inherits from or implements the other, or if they are the same explicitly declared type. This provides stronger guarantees about type identity but reduces flexibility. Nominal typing prevents the accidental interchange of types that might share the same structure but represent different concepts. In a nominal system, you couldn’t accidentally assign a Teacher to a variable expecting a Student, even if both classes had identical properties .
The Concept of Opaque Types
Opaque types represent a specialized form of nominal typing where the internal representation of a type is hidden from consumers. While internally, an opaque type might be based on a primitive like string or number, externally it appears as a distinct type that cannot be created or manipulated without going through specific constructor functions. This creates a abstraction barrier that prevents accidental misuse and ensures invariants are maintained. As one resource explains, “An opaque type is a type that is designed to be used in such a way that it appears to be one thing from the outside but in reality, is quite different on the inside” .
The power of opaque types lies in their ability to enforce business rules at the type level while hiding implementation details. For example, a UserId opaque type might internally be a string but could enforce requirements like specific formatting or validation logic through its constructor functions. Consumers of the API can use UserId values but cannot create them arbitrarily from strings, ensuring that all UserId instances in the system satisfy the required invariants. This approach significantly improves code reliability by making illegal states unrepresentable and reducing the surface area for bugs .
Benefits of Nominal Typing in TypeScript
Implementing nominal typing in TypeScript provides several significant benefits that enhance code quality and maintainability. First, it prevents accidental assignments between semantically different types that share the same structure. This is particularly valuable when working with primitive types like strings and numbers that represent different domain concepts. For example, with nominal typing, TypeScript would catch an attempt to assign a ProductId to a variable expecting a UserId, even though both are ultimately strings .
Secondly, nominal typing improves code documentation and readability. When you see a function parameter of type ValidatedInputString instead of just string, you immediately understand the expectations and constraints around that value. The type system becomes a form of executable documentation that communicates design intentions more clearly than comments alone could. This makes the codebase more self-documenting and easier for new developers to understand .
Finally, these techniques enhance overall type safety without introducing runtime overhead. Since most nominal typing approaches in TypeScript rely on type-level constructs that are erased during compilation, they provide additional safety checks during development without impacting runtime performance. This makes them particularly valuable for applications where correctness is critical, such as financial systems, security-sensitive code, or complex domain models with many similar-but-distinct types .
Branded Types: Intersection Types and Unique Symbols
Basic Branded Type Pattern
The branded type pattern is the most common approach to simulating nominal typing in TypeScript. It works by intersecting a base type (like string or number) with an object literal containing a unique brand property. This creates a new type that’s structurally different from the base type, preventing accidental assignments. The basic pattern can be implemented using a generic Brand type:
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, "UserId">;
type OrderId = Brand<number, "OrderId">;
let userId: UserId = 10 as UserId;
let orderId: OrderId = 20 as OrderId;
// This will cause a TypeScript error:
// Type 'OrderId' is not assignable to type 'UserId'
userId = orderId;The key insight is that while UserId and OrderId are both based on number, the brand property makes them structurally incompatible from TypeScript’s perspective. The __brand property never exists at runtime—it’s purely a type-level construct that enables the compiler to distinguish between different uses of the same underlying type .
Enhanced Branded Types with Unique Symbols
While simple string brands work well, using unique symbols provides additional safety by ensuring that brands cannot collide accidentally. This approach uses a unique symbol as the property key, making it impossible to accidentally create the same brand in different parts of the codebase:
declare const brand: unique symbol;
type Brand<K, T> = K & { readonly [brand]: T };
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
const usd = 100 as USD;
const eur = 100 as EUR;
// Error: Type 'EUR' is not assignable to type 'USD'
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
addUSD(usd, eur); // TypeScript errorThe unique symbol approach offers better encapsulation than string brands because the symbol property isn’t visible in autocomplete suggestions and cannot be accidentally accessed in regular code. This reduces the risk of developers trying to interact with the brand property directly, recognizing that it’s purely a type-level construct .
Opaque Types with Unique Symbols
For scenarios requiring even stronger encapsulation, opaque types build upon the branded pattern by completely hiding the internal structure. This approach is particularly useful for library authors or when building public APIs where you want to prevent consumers from depending on implementation details:
declare const __opaque__type__: unique symbol;
type OpaqueType<K, T> = K & { readonly [__opaque__type__]: T };
type UserId = OpaqueType<string, "UserId">;
type ProductId = OpaqueType<string, "ProductId">;
function createUserId(id: string): UserId {
// Validation logic can be added here
if (!id.startsWith("user_")) {
throw new Error("Invalid user ID format");
}
return id as UserId;
}
function getProductInfo(id: ProductId) {
// Function implementation
}
const userId = createUserId("user_123");
const productId = "product_456" as ProductId;
getProductInfo(userId); // TypeScript error: Argument of type 'UserId' is not assignable to type 'ProductId'The opaque type pattern ensures that values cannot be directly cast to the opaque type without going through validation logic, as the unique symbol isn’t exported from the module. This provides both type safety and runtime validation in a single construct .
Factory Functions for Branded Types
Creating branded types directly through type assertions (as UserId) bypasses potential validation. A more robust approach uses factory functions that centralize the creation of branded values and enforce any necessary constraints:
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
function UserId(id: string): UserId {
if (!id || id.length === 0) {
throw new Error("User ID cannot be empty");
}
return id as UserId;
}
function Email(address: string): Email {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(address)) {
throw new Error("Invalid email format");
}
return address as Email;
}
// Usage
const userId = UserId("user123");
const email = Email("user@example.com");
// Both lines below would cause TypeScript errors:
// const invalidUserId: UserId = "user123";
// const invalidEmail: Email = "not-an-email";Factory functions provide a single source of truth for creating branded values while encapsulating validation logic. This approach combines compile-time type safety with runtime validation, offering the best of both worlds. Consumers of your API cannot create instances of your branded types without going through the validation performed by the factory functions .
Class-Based Nominal Typing Approaches
Classes with Private Properties
Using classes with private properties provides a straightforward way to create nominal types in TypeScript. When a class contains a private property, TypeScript considers two classes compatible only if they share the same declaration of that private property. This behavior effectively creates nominal typing without any additional patterns:
class USD {
private __nominal: void;
constructor(public value: number) {}
}
class EUR {
private __nominal: void;
constructor(public value: number) {}
}
const usd = new USD(100);
const eur = new EUR(100);
function gross(net: USD, tax: USD): USD {
return new USD(net.value + tax.value);
}
gross(usd, usd); // Valid: both parameters are USD
gross(eur, usd); // Error: Types have separate declarations of a private property '__nominal'The advantage of this approach is that it doesn’t require type assertions—the TypeScript compiler correctly infers the nominal nature of the types. However, the need to instantiate classes rather than use primitive values introduces runtime overhead that might be unnecessary for some use cases. This approach works best when you genuinely need an object wrapper around your primitive values .
Generic Branded Classes
For a more reusable approach, generic branded classes combine the encapsulation of classes with the flexibility of generics. This pattern uses an empty class with a generic type parameter to create distinct types:
class Currency<T extends string> {
private as: T;
}
type USD = number & Currency<"USD">;
type EUR = number & Currency<"EUR">;
const usd = 10 as USD;
const eur = 10 as EUR;
function convertToUSD(eur: EUR): USD {
return (eur * 1.1) as USD;
}
// Error: Type '"EUR"' is not assignable to type '"USD"'
usd = eur;In this pattern, the Currency class serves as a type-level marker that never exists at runtime. The private as property ensures structural incompatibility between different currency types. While this approach creates an empty class in the compiled JavaScript, it provides excellent type safety and clear error messages .
Branded Classes with Symbols
Combining classes with symbols creates the strongest form of nominal typing, suitable for high-integrity applications where type confusion must be prevented at all costs:
const UserIdBrand = Symbol('UserIdBrand');
const OrderIdBrand = Symbol('OrderIdBrand');
class UserId {
private readonly _brand = UserIdBrand;
constructor(public value: string) {}
}
class OrderId {
private readonly _brand = OrderIdBrand;
constructor(public value: string) {}
}
function processUser(id: UserId) {
console.log(`Processing user: ${id.value}`);
}
const userId = new UserId('user123');
const orderId = new OrderId('order456');
processUser(userId); // Valid
processUser(orderId); // Error: Type 'OrderId' is not assignable to type 'UserId'This approach provides runtime branding through the symbol property while maintaining the full functionality of classes. Although it has more runtime overhead than purely type-level solutions, it offers the strongest guarantees and works well in applications that already use classes extensively for domain modeling .
Practical Applications and Use Cases
Distinct Identifiers
One of the most common applications of nominal typing is creating distinct identifier types for different entities in a system. In most applications, identifiers for users, orders, products, and other entities are all represented as strings or numbers, making them easily interchangeable by mistake:
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type ProductId = Brand<string, "ProductId">;
function getUser(id: UserId) { /* ... */ }
function getOrder(id: OrderId) { /* ... */ }
function getProduct(id: ProductId) { /* ... */ }
// These will all cause TypeScript errors:
getUser("user_123" as OrderId);
getOrder("order_456" as ProductId);
getProduct("product_789" as UserId);By applying nominal typing to identifiers, you prevent an entire class of bugs where the wrong identifier is passed to a function. The TypeScript compiler will catch these errors during development, long before they cause issues in production. This approach is particularly valuable in large codebases with multiple developers, where it’s easy to lose track of which identifier type a function expects .
Currency and Financial Data
Financial applications require precise handling of different currencies to prevent costly errors. Nominal typing ensures that currencies cannot be accidentally mixed without explicit conversion:
type USD = Brand<number, "USD">;
type EUR = Brand<number, "EUR">;
type JPY = Brand<number, "JPY">;
function addUSD(a: USD, b: USD): USD {
return (a + b) as USD;
}
function convertEURtoUSD(eur: EUR, rate: number): USD {
return (eur * rate) as USD;
}
const balanceUSD = 100 as USD;
const balanceEUR = 100 as EUR;
addUSD(balanceUSD, balanceUSD); // Valid
addUSD(balanceUSD, balanceEUR); // TypeScript error
addUSD(balanceUSD, convertEURtoUSD(balanceEUR, 1.1)); // Valid after conversionThis approach makes currency conversions explicit in the code, reducing the risk of accidental miscalculations. The type system enforces that currencies can only be combined after appropriate conversion, matching the business rules that would apply in the real world .
Units of Measurement
Scientific, engineering, and mapping applications often work with different units of measurement that should not be mixed arbitrarily. Nominal typing prevents accidents like adding meters to kilometers without conversion:
type Meter = Brand<number, "Meter">;
type Kilometer = Brand<number, "Kilometer">;
type Second = Brand<number, "Second">;
function convertKilometersToMeters(km: Kilometer): Meter {
return (km * 1000) as Meter;
}
function calculateSpeed(distance: Meter, time: Second): number {
return distance / time;
}
const distanceInMeters = 1000 as Meter;
const distanceInKilometers = 1 as Kilometer;
const timeInSeconds = 10 as Second;
// Error: Type 'Kilometer' is not assignable to type 'Meter'
calculateSpeed(distanceInKilometers, timeInSeconds);
// Valid after conversion
calculateSpeed(convertKilometersToMeters(distanceInKilometers), timeInSeconds);This application of nominal typing is particularly valuable in domains where unit errors could have serious consequences, such as aerospace, medical devices, or scientific computing. The type system serves as an automated proof that units are being handled correctly throughout the application .
Security and Validation
Nominal types can distinguish between validated and unvalidated data, creating a security boundary within the type system itself. This pattern is especially useful for preventing security vulnerabilities like injection attacks:
type UnsafeString = string;
type SanitizedHTML = Brand<string, "SanitizedHTML">;
type SQLParameter = Brand<string, "SQLParameter">;
function sanitizeHTML(input: UnsafeString): SanitizedHTML {
// Actual sanitization logic here
return input.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') as SanitizedHTML;
}
function buildSQLQuery(query: string, param: SQLParameter): string {
return query.replace('?', param.toString());
}
const userInput = "<script>alert('xss')</script>Hello World";
const sanitized = sanitizeHTML(userInput);
// These would cause TypeScript errors:
// document.body.innerHTML = userInput; // Using unsafe string directly
// buildSQLQuery("SELECT * FROM users WHERE name = ?", userInput); // Using unsanitized input in SQL
// These are safe:
document.body.innerHTML = sanitized;
buildSQLQuery("SELECT * FROM users WHERE name = ?", "safe_value" as SQLParameter);This approach uses the type system to track data provenance, ensuring that untrusted data undergoes proper validation before being used in security-sensitive contexts. The compiler effectively enforces a security policy by distinguishing between data that has been sanitized and data that hasn’t .
Advanced Patterns, Limitations, and Best Practices
Validation and Construction Patterns
A common challenge with nominal types is ensuring that values are properly validated when they’re created. Simple type assertions (as UserId) bypass validation, potentially allowing invalid values into the system. A more robust approach combines factory functions with validation libraries:
import { z } from 'zod';
const UserIdSchema = z.string().uuid().brand<'UserId'>();
type UserId = z.infer<typeof UserIdSchema>;
const EmailSchema = z.string().email().brand<'Email'>();
type Email = z.infer<typeof EmailSchema>;
function createUserId(id: string): UserId {
return UserIdSchema.parse(id);
}
function createEmail(address: string): Email {
return EmailSchema.parse(address);
}
// These will throw validation errors at runtime:
// const invalidUserId = createUserId("not-a-uuid");
// const invalidEmail = createEmail("not-an-email");
// These are valid:
const validUserId = createUserId("f47ac10b-58cc-4372-a567-0e02b2c3d479");
const validEmail = createEmail("user@example.com");This pattern combines compile-time type safety with runtime validation, providing comprehensive protection against invalid data. The brand type parameters ensure that the resulting types are nominal, while the validation schemas guarantee that the values meet business requirements .
Limitations and Considerations
While nominal typing techniques provide significant benefits, they also come with important limitations to consider. First, these patterns exist only at compile time—the brand markers are completely erased during compilation to JavaScript. This means there’s no runtime enforcement of type distinctions, so you need complementary runtime checks for complete security .
Secondly, nominal typing introduces additional boilerplate through type assertions, factory functions, or class wrappers. This can make the code slightly more verbose and require buy-in from the entire development team to use consistently. Some approaches, particularly class-based solutions, also introduce minimal runtime overhead compared to using primitives directly .
Another consideration is that serialization and deserialization (e.g., when sending data over HTTP or storing in a database) requires special attention. When you deserialize a JSON string into an object, the resulting values won’t have the nominal types automatically applied:
type UserId = Brand<string, "UserId">;
interface User {
id: UserId;
name: string;
}
// After deserializing from JSON, we need to reapply the nominal typing
const dataFromAPI = JSON.parse('{"id": "user123", "name": "John"}');
const user: User = {
id: dataFromAPI.id as UserId, // Need explicit assertion
name: dataFromAPI.name
};This limitation means you need to be deliberate about applying nominal types at system boundaries, which can be partially mitigated through validation libraries that handle both validation and branding .
Comparison of Approaches
Different nominal typing techniques suit different scenarios. The table below summarizes the key characteristics of each major approach:
| Approach | Type Safety | Runtime Overhead | Boilerplate | Error Messages |
|---|---|---|---|---|
| Basic Branding | High | None | Low | Clear |
| Unique Symbols | Highest | None | Medium | Clear |
| Class with Private | High | Minimal | Medium | Very Clear |
| Opaque Types | Highest | None | Medium | Clear |
The choice between these approaches depends on your specific needs. For most applications, basic branding or unique symbol approaches provide the best balance of safety and simplicity. When working with existing class hierarchies, class-based approaches integrate more naturally. For library authors or security-sensitive applications, opaque types provide the strongest encapsulation .
Conclusion
Nominal typing techniques fill an important gap in TypeScript’s structural type system, allowing developers to prevent logical errors that occur when semantically different types share the same structure. Through branded types, opaque types, and class-based patterns, TypeScript developers can create fine-grained type distinctions that catch bugs during development rather than in production. These patterns are particularly valuable for distinguishing between different types of identifiers, currencies, units of measurement, and validation states .
While each approach has different trade-offs in terms of boilerplate, encapsulation, and runtime characteristics, they all share the common benefit of making illegal states unrepresentable. By choosing the appropriate nominal typing strategy for your specific use case and applying it consistently throughout your codebase, you can significantly improve type safety, enhance code documentation, and create more robust applications. As TypeScript continues to evolve, these patterns provide a powerful way to extend its type system to capture important domain semantics that would otherwise exist only in documentation or developers’ minds .
The techniques discussed in this article demonstrate that TypeScript’s type system is remarkably flexible, capable of supporting both structural and nominal typing patterns as needed. By leveraging this flexibility, you can enjoy the benefits of structural typing where it makes sense while enforcing nominal type safety in critical areas where the semantic meaning of data is as important as its structure.
References
- Nominal typing techniques in TypeScript – Michal Zalecki
- TypeScript — nominal typing and branded types
- Opaque Types In TypeScript
- Nominal types in TypeScript | Dimitrios Lytras
- Playground Example – Nominal Typing
- Methodological options of the nominal group technique for survey item elicitation in health research
- Opaque Types in TypeScript | Alexey Berezin
- Refining TypeScript’s Opaque Types for Enhanced Type Safety
- Nominal Typing in TypeScript
- Advanced Typescript: Tagged Types for Fewer Bugs and Better Security
