Phantom Types
Create nominal types without runtime overhead using phantom type patterns
Phantom types are the foundation of typist's approach to type-level programming. They allow you to work with types as if they were runtime values , enabling powerful compile-time analysis and validation.
A phantom type carries information at the type level but has no corresponding runtime representation. In typist, we create phantom values that "lie to the compiler" by casting null to any type we want to work with.
Understanding Phantom Types
Phantom types are a powerful pattern that allows you to create distinct types that share the same runtime representation but are treated as completely different by the type system. This enables you to encode additional information in types without any runtime overhead.
The Problem: Primitive Obsession
Consider this common scenario where we use strings for different purposes:
// Problematic: All are just strings at the type level
function createUser(userId: string, email: string, name: string) {
// Easy to accidentally swap parameters!
return saveUser(email, userId, name); // ❌ Bug: swapped userId and email
}
function sendEmail(to: string, from: string, subject: string) {
// What if someone passes a user ID instead of an email?
mail.send(to, from, subject);
}
// Usage - no protection against mistakes
createUser("user@example.com", "123", "John"); // ❌ Parameters swapped!The Solution: Phantom Types
With phantom types, we can create distinct types that prevent these mistakes:
// Basic phantom value creation examples
import { t_, type_, t } from '@type-first/typist';
// Create phantom values for any type
const user = t_<{ name: string; age: number }>();
const id = t_<string>();
const config = t_<{ theme: 'dark' | 'light' }>();
// All equivalent ways to create phantom values
const value1 = t_<string>();
const value2 = type_<string>();
const value3 = t<string>();
// Complex types work too
type ApiResponse<T> = {
data: T;
status: number;
message: string;
};
const response = t_<ApiResponse<User>>();
// Use phantom values for type-level operations
type UserType = typeof user; // { name: string; age: number }Creating Phantom Values
Typist provides several utilities for creating phantom values. These are compile-time-only constructs that help you work with phantom types:
import { t_, type_, t } from '@type-first/typist';
// All of these create the same phantom value
const userIdPhantom = t_<UserId>();
const userIdPhantom2 = type_<UserId>();
const userIdPhantom3 = t<UserId>();
// You can use phantom values in type-level operations
type UserIdType = typeof userIdPhantom; // UserId
type IsUserId = $Equal<UserIdType, UserId>; // $Yes
// Phantom values for complex types
type ApiResponse<T> = {
data: T;
status: number;
success: boolean;
};
const responsePhantom = t_<ApiResponse<User>>();
type ResponseType = typeof responsePhantom; // ApiResponse<User>Branded Types Pattern
The most common way to implement phantom types is through "branded types" using intersection types:
// Brand pattern: BaseType & { readonly __brand: 'BrandName' }
type UserId = string & { readonly __brand: 'UserId' };
type Email = string & { readonly __brand: 'Email' };
type ProductId = string & { readonly __brand: 'ProductId' };
// Helper functions for creating branded values
function createUserId(id: string): UserId {
// In real code, you might validate the format here
return id as UserId;
}
function createEmail(email: string): Email {
// Email validation logic
if (!email.includes('@')) {
throw new Error('Invalid email');
}
return email as Email;
}
// Now our function is type-safe
function createUser(userId: UserId, email: Email, name: string) {
return {
id: userId,
email: email,
name: name
};
}
// Usage
const userId = createUserId("user_123");
const email = createEmail("user@example.com");
createUser(userId, email, "John"); // ✅ Correct order enforced
// createUser(email, userId, "John"); // ❌ Compile error!Advanced Phantom Type Patterns
State Machine Types
Phantom types can represent different states in a state machine:
type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error';
type Connection<TState extends ConnectionState> = {
readonly state: TState;
readonly __phantom: TState;
} & (
TState extends 'disconnected' ? { connect(): Connection<'connecting'> } :
TState extends 'connecting' ? { cancel(): Connection<'disconnected'> } :
TState extends 'connected' ? {
disconnect(): Connection<'disconnected'>;
send(data: string): void;
} :
TState extends 'error' ? { retry(): Connection<'connecting'> } :
never
);
// Usage
declare const disconnectedConn: Connection<'disconnected'>;
const connecting = disconnectedConn.connect(); // Type: Connection<'connecting'>
const connected = connecting; // In real code, this would be the result of awaiting connection
if (connected.state === 'connected') {
connected.send("Hello!"); // ✅ Only available when connected
// connected.connect(); // ❌ connect() not available when already connected
}Units of Measure
Phantom types can encode units of measurement:
type Meters = number & { readonly __unit: 'meters' };
type Feet = number & { readonly __unit: 'feet' };
type Seconds = number & { readonly __unit: 'seconds' };
function meters(value: number): Meters {
return value as Meters;
}
function feet(value: number): Feet {
return value as Feet;
}
function seconds(value: number): Seconds {
return value as Seconds;
}
// Conversion functions
function metersToFeet(m: Meters): Feet {
return feet(m * 3.28084);
}
function calculateSpeed(distance: Meters, time: Seconds): number {
return distance / time; // Returns meters per second
}
// Usage
const distance = meters(100);
const time = seconds(10);
const speed = calculateSpeed(distance, time); // ✅ Type safe
// calculateSpeed(feet(100), time); // ❌ Can't mix units!Permission Systems
Model permission levels with phantom types:
type Permission = 'read' | 'write' | 'admin';
type SecureData<TPermission extends Permission> = {
data: string;
readonly __permission: TPermission;
};
type User<TPermission extends Permission> = {
id: UserId;
name: string;
readonly __permission: TPermission;
};
// Only admin users can access admin data
function accessAdminData<T extends Permission>(
user: User<T>,
data: SecureData<'admin'>
): T extends 'admin' ? string : never {
// Compile-time check that user has admin permission
return user.__permission extends 'admin' ? data.data as any : never as any;
}
// Usage
declare const adminUser: User<'admin'>;
declare const readUser: User<'read'>;
declare const adminData: SecureData<'admin'>;
const result1 = accessAdminData(adminUser, adminData); // ✅ Works
// const result2 = accessAdminData(readUser, adminData); // ❌ Compile errorTesting Phantom Types
Use typist's testing utilities to verify your phantom type implementations:
import { $Equal, $Extends, yes_, no_, test_ } from '@type-first/typist';
const phantomTypeTests = test_('Phantom type validation', () => {
// Test that branded types are distinct
no_<$Equal<UserId, Email>>();
no_<$Equal<UserId, string>>();
no_<$Equal<Email, string>>();
// Test that they extend their base type for runtime compatibility
yes_<$Extends<UserId, string>>();
yes_<$Extends<Email, string>>();
// Test state machine transitions
type DisconnectedConn = Connection<'disconnected'>;
type ConnectingConn = Connection<'connecting'>;
// Verify state machine structure
yes_<$Extends<DisconnectedConn, { connect(): any }>>();
no_<$Extends<DisconnectedConn, { send(data: string): void }>>();
return t_<boolean>();
});Best Practices
Naming Conventions
- Use descriptive names that indicate the domain:
UserId,EmailAddress,ProductSku - For units, include the unit in the name:
Meters,Seconds,Dollars - For states, use clear state names:
PendingOrder,ConfirmedOrder
Brand Property
- Use consistent brand property names:
__brand,__phantom, or__tag - Make brand properties
readonlyto prevent runtime modification - Use string literals for brand values to enable better error messages
Creation Functions
- Provide factory functions for creating branded values safely
- Include validation logic in factory functions
- Use descriptive names:
createUserId,parseEmail,validateProductSku
Common Pitfalls
Runtime Confusion
Remember that phantom types are compile-time only. At runtime, branded values are just their base type:
const userId: UserId = "user_123" as UserId;
console.log(typeof userId); // "string" - not "UserId"!
// Don't rely on runtime type checking
if (userId instanceof UserId) { // ❌ This doesn't work
// ...
}
// Instead, use validation functions
function isValidUserId(value: string): value is UserId {
return value.startsWith('user_');
}Excessive Branding
Don't create phantom types for every string or number. Only use them when:
- Values can be easily confused (IDs, emails, phone numbers)
- Type safety provides significant business value
- The domain naturally has distinct concepts
💡 Key Takeaway
Phantom types are about encoding domain knowledge in the type system. They help prevent bugs by making invalid states unrepresentable, turning runtime errors into compile-time errors.
Try It Yourself
Explore phantom types interactively with these hands-on scenarios in our Type Explorer:
Phantom Types Basics
Learn the fundamentals of phantom types and type-level programming with typist. Create phantom values and build branded types.