erve complementary roles in type system design. interface should be the default for structural definitions where extensibility and clarity are paramount. type should be reserved for algebraic data modeling (unions), utility transformations, and scenarios requiring precise type manipulation. Mixing these roles leads to fragile architectures where domain models are either too rigid or too opaque.
Core Solution
Implementing a robust type strategy requires aligning the choice of interface or type with the semantic intent of the domain model. The following implementation demonstrates a production-grade approach using a financial transaction domain.
Step 1: Define Extensible Domain Entities with interface
Domain entities often require extension by plugins, middleware, or future requirements. interface supports declaration merging, allowing multiple declarations to combine into a single shape. This is critical for public APIs and core domain models.
// Base entity with natural inheritance
interface TransactionBase {
id: string;
timestamp: number;
metadata: Record<string, unknown>;
}
// Clear inheritance hierarchy
interface PaymentTransaction extends TransactionBase {
amount: number;
currency: string;
payerId: string;
}
// Declaration merging: Third-party modules can augment this interface
// without modifying the original source file.
interface PaymentTransaction {
riskScore?: number;
fraudFlags?: string[];
}
// Usage: The compiler merges all declarations automatically.
const tx: PaymentTransaction = {
id: "txn_123",
timestamp: Date.now(),
metadata: {},
amount: 100.00,
currency: "USD",
payerId: "usr_456",
riskScore: 0.85, // Augmented field
};
Rationale: TransactionBase and PaymentTransaction are interfaces because they represent structural contracts that may evolve. The extends keyword provides a clear inheritance chain, and declaration merging allows safe augmentation. This pattern ensures that the domain model remains open for extension but closed for modification, adhering to the Open/Closed Principle.
Step 2: Model State Machines with Discriminated Unions (type)
State machines and event-driven architectures benefit from discriminated unions, which provide exhaustive type checking and narrow types based on a discriminator field. type aliases are required for union types.
// Discriminated union for transaction lifecycle
type TransactionState =
| { kind: "PENDING"; initiatedAt: number }
| { kind: "PROCESSING"; processorId: string }
| { kind: "COMPLETED"; receiptId: string; settledAt: number }
| { kind: "FAILED"; errorCode: string; retryable: boolean };
// Type-safe state handler
function handleTransactionState(state: TransactionState): void {
switch (state.kind) {
case "PENDING":
console.log(`Transaction pending since ${state.initiatedAt}`);
break;
case "PROCESSING":
console.log(`Handled by ${state.processorId}`);
break;
case "COMPLETED":
console.log(`Settled with receipt ${state.receiptId}`);
break;
case "FAILED":
if (state.retryable) {
console.log(`Retrying failed transaction: ${state.errorCode}`);
} else {
console.log(`Terminal failure: ${state.errorCode}`);
}
break;
default:
// Exhaustiveness check: TypeScript ensures all cases are handled.
const _exhaustiveCheck: never = state;
throw new Error(`Unhandled state: ${_exhaustiveCheck}`);
}
}
Rationale: TransactionState is a type alias because it represents a closed set of mutually exclusive states. The kind field acts as a discriminator, enabling the compiler to narrow the type within each branch. This pattern eliminates runtime errors caused by invalid state transitions and ensures that all state variations are explicitly handled.
Step 3: Create Utility Types with Intersections and Mapped Types (type)
Utility types often involve generic transformations, intersections, or conditional logic. type aliases provide the flexibility needed for these advanced constructs.
// Generic intersection utility
type Auditable<T> = T & {
auditTrail: { action: string; timestamp: number; actor: string }[];
};
// Mapped type for optional fields
type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
// Conditional type for result wrapping
type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
// Application of utilities
type AuditedPayment = Auditable<PaymentTransaction>;
type OptionalCurrencyPayment = PartialBy<PaymentTransaction, "currency">;
function processPayment(payment: AuditedPayment): Result<string> {
// Implementation logic
return { success: true, data: "processed" };
}
Rationale: Auditable, PartialBy, and Result are type aliases because they involve generic parameters, intersections, and conditional logic that interface cannot express. These utilities transform existing types rather than defining new structural contracts, making type the appropriate choice.
Pitfall Guide
Avoiding common mistakes in type system design requires understanding the limitations and performance characteristics of each construct.
| Pitfall | Explanation | Fix |
|---|
| The Augmentation Block | Using type for public API shapes prevents consumers from extending definitions via declaration merging. This breaks plugin architectures and forces invasive modifications. | Use interface for all public API shapes and domain entities that may require augmentation. Reserve type for internal or closed definitions. |
| The Conditional Labyrinth | Writing deeply nested conditional or mapped types can cause exponential compilation time growth and produce error messages spanning dozens of lines. | Simplify type logic where possible. Break complex types into smaller, named aliases. Use --extendedDiagnostics to identify performance bottlenecks. |
| Merging Conflicts | Relying on declaration merging without clear documentation can lead to conflicting signatures or unexpected type combinations. | Document merging points explicitly. Avoid merging on critical internal types. Use linting rules to enforce merging conventions. |
| Tuple Semantics Loss | Using type for tuples without named elements reduces readability and increases the risk of index errors. | Use named tuples (e.g., type Point = [x: number, y: number]) where supported. Consider interfaces with index signatures for complex tuple-like structures. |
| Error Obscurity | Complex type aliases can mask the root cause of type errors, making debugging difficult. | Use intermediate variables to isolate type errors. Hover over types in the editor to inspect resolved shapes. Refactor opaque types into named interfaces. |
The any Escape Hatch | Overly complex types may force developers to use any to bypass type checking, undermining type safety. | Simplify types to reduce friction. Provide clear error messages and documentation. Encourage incremental adoption of stricter types. |
| Inconsistency | Mixing interface and type arbitrarily leads to cognitive overhead and maintenance challenges. | Establish team conventions. Use ESLint rules to enforce consistent usage. Document the rationale for each choice. |
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public Library API | interface | Allows consumer augmentation via declaration merging. | Low |
| Internal State Machine | type | Discriminated unions provide exhaustive type checking. | Low |
| Third-Party Augmentation | interface | Required for safe extension of external types. | Low |
| Utility Transformation | type | Mapped/conditional types require alias flexibility. | Medium |
| High-Perf Critical Path | interface | Lower compilation overhead compared to complex generics. | Low |
| Tuple Definitions | type | Named tuples improve readability and type safety. | Low |
Configuration Template
tsconfig.json
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"extendedDiagnostics": true,
"generateTrace": "./ts-trace"
},
"include": ["src/**/*.ts"]
}
ESLint Configuration
{
"rules": {
"@typescript-eslint/consistent-type-definitions": ["error", "interface"],
"@typescript-eslint/consistent-type-assertions": "error"
}
}
Quick Start Guide
- Define Base Entities: Create domain entities using
interface with extends for clear inheritance hierarchies.
- Model States: Implement state machines using
type aliases with discriminated unions and exhaustive checks.
- Create Utilities: Define generic transformations and intersections using
type aliases.
- Validate Performance: Run
tsc --extendedDiagnostics to identify and optimize slow type checks.
- Enforce Consistency: Apply ESLint rules to maintain uniform type definitions across the codebase.