Don’t rely on empty interface extensions for semantic type safety in TypeScript
The Problem
TypeScript uses structural typing (duck typing) rather than nominal typing. This means types are compatible if they have the same structure, regardless of their names or semantic meaning. This can create dangerous bugs when types should be semantically different but happen to share the same shape.
Real-World Risk Example
interface NutFreeIngredient extends Ingredient {}
interface PeanutButter extends Ingredient {}
function makeSafeSnackForAllergicPerson(safeIngredient: NutFreeIngredient) {
return `Making safe snack with ${safeIngredient.name}`;
}
const peanutButter: PeanutButter = { name: "Creamy Peanut Butter", calories: 200 };
// ❌ This compiles! TypeScript sees them as compatible.
makeSafeSnackForAllergicPerson(peanutButter);Both types extend Ingredient with no additional properties, so TypeScript considers them interchangeable. The type system fails to protect against a potentially dangerous mistake.
Solution 1: Branded Types (Industry Standard) ✅
Branded types are the most widely recommended approach for nominal typing in TypeScript, used by the TypeScript compiler team itself. The brand property exists only at compile-time and has zero runtime overhead.
Basic Pattern (Type Alias)
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<number, 'UserId'>;
type OrderId = Brand<number, 'OrderId'>;
const userId = 1 as UserId;
const orderId = 2 as OrderId;
// ❌ Type error! Cannot mix these types
// userId = orderId;Note: The TypeScript team uses _ prefix and Brand suffix as a convention (e.g., _fooIdBrand), though __brand is also widely used in the community.
Interface Extension Pattern
For complex types, extend interfaces directly:
interface NutFreeIngredient extends Ingredient {
readonly __brand: 'NutFreeIngredient';
}
interface PeanutButter extends Ingredient {
readonly __brand: 'PeanutButter';
}
// Real-world example:
export interface HomeThorgLogsDirectory extends IDirectory {
readonly __brand: 'HomeThorgLogsDirectory';
}Creating Branded Values
You must use one of these approaches:
Option A: Type Assertion (Simple)
const userId = 123 as UserId;Option B: Factory Function (With Validation)
function createUserId(value: number): UserId {
if (value <= 0) throw new Error('Invalid user ID');
return value as UserId;
}Option C: Type Guard Pattern
function isUserId(value: unknown): value is UserId {
return typeof value === 'number' && value > 0;
}There is no way to create branded types with plain object literals - you must use assertions or factory functions.
Solution 2: Discriminated Unions (For Multiple Variants)
Discriminated unions work best for sets of similar objects with different variants, like named events or versioned objects.
interface NutFreeIngredient extends Ingredient {
ingredientType: 'NUT_FREE';
}
interface PeanutButter extends Ingredient {
ingredientType: 'PEANUT_BUTTER';
}
// ✅ Direct object literal creation
const apple: NutFreeIngredient = {
name: "Apple",
calories: 95,
ingredientType: 'NUT_FREE'
};
// ✅ TypeScript provides excellent narrowing
function processIngredient(ingredient: NutFreeIngredient | PeanutButter) {
switch (ingredient.ingredientType) {
case 'NUT_FREE':
// TypeScript knows this is NutFreeIngredient
return makeSafeSnack(ingredient);
case 'PEANUT_BUTTER':
// TypeScript knows this is PeanutButter
return handleWithCaution(ingredient);
}
}Pros:
- No factory functions needed
- Direct object literal creation
- Improved type safety with exhaustiveness checking
- Runtime type checking possible
- Excellent IDE autocomplete
Cons:
- Runtime memory overhead (extra property)
- Must include discriminant in every object
When to Use Each
Use Branded Types When:
- Working with identifiers (UserId, OrderId, ProductId)
- Preventing division by zero or similar runtime errors (NonZero, PositiveNumber)
- Differentiating sanitized vs unsanitized data (SafeHTML, ValidatedEmail)
- Working with primitive-like types where the brand is purely semantic
- Zero runtime overhead is critical
- You already have factory functions or constructors
Use Discriminated Unions When:
- Modeling different variants of similar objects (events, API responses, versioned data)
- Representing multiple possible states or outcomes
- You need runtime type checking
- You want exhaustiveness checking in switch statements
- Convenience of object literals is important
- You have many types with overlap between properties
Key Takeaway
Don’t rely on empty interface extensions for semantic type safety in TypeScript. Branded types are the industry standard for nominal typing, especially for simple types like IDs and validated primitives. Discriminated unions excel when you need to model multiple related variants with runtime type checking. Both patterns are essential tools in TypeScript’s type safety toolkit.
The tradeoff is unavoidable: branded types require factory functions or assertions, while discriminated unions require runtime properties. Choose based on your use case.