Loading content...
In the physical world, contracts are everywhere. When you plug a USB device into any computer, you're relying on a contract—a standardized agreement about how power and data flow through those pins. When you insert a credit card into any payment terminal worldwide, you're depending on contractual specifications that ensure interoperability. The device manufacturer and the terminal manufacturer never coordinated directly, yet their products work together seamlessly.
This is the essence of interfaces in object-oriented programming: they define behavioral contracts that allow unrelated components to work together without knowing each other's internal details.
Interfaces represent one of the most profound concepts in software engineering—the idea that we can specify what something can do without specifying how it does it. This separation of specification from implementation is the cornerstone of flexible, extensible software design.
By the end of this page, you will understand what interfaces truly represent at a conceptual level, how they function as behavioral contracts, why they're essential for genuine polymorphism, and how the contract metaphor guides proper interface design. You'll see why interfaces are often described as the most important abstraction mechanism in object-oriented programming.
At its core, an interface is a collection of method signatures—declarations of methods without their implementations. It defines a set of operations that any implementing class must provide, but says nothing about how those operations should be performed.
Think of an interface as a job description. A job description for a "driver" might specify:
This job description doesn't care whether the driver learned at a professional school or from a parent, whether they prefer defensive or aggressive driving styles, or what their personal philosophy about lane changes is. It only specifies the capabilities required.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
// An interface defines WHAT operations are available// It says nothing about HOW they're implemented interface Driver { startVehicle(): void; accelerate(speed: number): void; brake(): void; turn(direction: 'left' | 'right'): void;} // Any class implementing Driver MUST provide all these methodsclass ProfessionalDriver implements Driver { startVehicle(): void { console.log("Professional startup sequence initiated..."); // Thorough pre-drive checks, mirror adjustment, etc. } accelerate(speed: number): void { console.log(`Smoothly accelerating to ${speed} km/h`); // Gradual, fuel-efficient acceleration } brake(): void { console.log("Applying progressive braking technique"); // Engine braking followed by gradual pedal application } turn(direction: 'left' | 'right'): void { console.log(`Executing precise ${direction} turn with proper signaling`); // Perfect timing, signaling, mirror checks }} class NoviceDriver implements Driver { startVehicle(): void { console.log("Uh... which pedal was the clutch again?"); // Stalls twice before successfully starting } accelerate(speed: number): void { console.log(`Lurching forward toward ${speed} km/h`); // Jerky acceleration with occasional stalling } brake(): void { console.log("STOPPING HARD!"); // Abrupt braking that tests seatbelt effectiveness } turn(direction: 'left' | 'right'): void { console.log(`Making a ${direction} turn... hopefully`); // Wide turns, uncertain timing }} // Both are valid Drivers - the interface doesn't care about skill levelfunction conductDrivingTest(driver: Driver): void { driver.startVehicle(); driver.accelerate(50); driver.turn('left'); driver.brake();}The profound insight here is that conductDrivingTest function doesn't know or care which type of driver it receives. It only knows that whatever object it receives can do the things a Driver should be able to do. The actual behavior—whether the driving is smooth or jerky—is determined by the specific implementation.
This is polymorphism in its purest form: one interface, many implementations.
The term "contract" isn't just a metaphor—it captures the legal and binding nature of interfaces. When a class declares that it implements an interface, it's making a binding promise to the rest of the system:
"I solemnly swear that I will provide implementations for every method declared in this interface, and any code that uses this interface can rely on me to fulfill these obligations."
Let's explore what this contract entails and the obligations it creates:
brake() accelerates the car, you've technically fulfilled the syntactic contract but violated the semantic one.Compilers can only verify syntactic contracts—that methods exist with correct signatures. Semantic contracts—that methods behave correctly—cannot be verified by compilers. A sort() method that reverses instead of sorts still compiles. Upholding semantic contracts requires developer discipline, documentation, and testing. This is why interface documentation and clear naming are crucial.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// The PaymentProcessor interface establishes a contractinterface PaymentProcessor { // Methods define capabilities any processor must have processPayment(amount: number, currency: string): PaymentResult; refundPayment(transactionId: string): RefundResult; getTransactionStatus(transactionId: string): TransactionStatus;} interface PaymentResult { success: boolean; transactionId: string; timestamp: Date;} interface RefundResult { success: boolean; refundId: string; originalTransactionId: string;} type TransactionStatus = 'pending' | 'completed' | 'failed' | 'refunded'; // Stripe implementation fulfills the contractclass StripeProcessor implements PaymentProcessor { processPayment(amount: number, currency: string): PaymentResult { // Stripe-specific API calls console.log(`Processing ${amount} ${currency} via Stripe`); return { success: true, transactionId: `stripe_${Date.now()}`, timestamp: new Date() }; } refundPayment(transactionId: string): RefundResult { // Stripe-specific refund logic console.log(`Refunding ${transactionId} via Stripe`); return { success: true, refundId: `stripe_refund_${Date.now()}`, originalTransactionId: transactionId }; } getTransactionStatus(transactionId: string): TransactionStatus { // Stripe-specific status check return 'completed'; }} // PayPal implementation fulfills the SAME contract, differentlyclass PayPalProcessor implements PaymentProcessor { processPayment(amount: number, currency: string): PaymentResult { // PayPal-specific API calls console.log(`Processing ${amount} ${currency} via PayPal`); return { success: true, transactionId: `paypal_${Date.now()}`, timestamp: new Date() }; } refundPayment(transactionId: string): RefundResult { // PayPal-specific refund logic console.log(`Refunding ${transactionId} via PayPal`); return { success: true, refundId: `paypal_refund_${Date.now()}`, originalTransactionId: transactionId }; } getTransactionStatus(transactionId: string): TransactionStatus { // PayPal-specific status check return 'completed'; }} // Client code depends ONLY on the contract, not specific implementationsclass CheckoutService { private processor: PaymentProcessor; // Dependency on interface, not class constructor(processor: PaymentProcessor) { this.processor = processor; } checkout(amount: number): PaymentResult { // Works with ANY PaymentProcessor implementation return this.processor.processPayment(amount, 'USD'); }} // Usage - easily swap implementationsconst stripeCheckout = new CheckoutService(new StripeProcessor());const paypalCheckout = new CheckoutService(new PayPalProcessor()); // Both work identically from the client's perspectivestripeCheckout.checkout(99.99);paypalCheckout.checkout(99.99);The contract metaphor illuminates why interfaces are the foundation of true polymorphism. When code depends on an interface rather than a concrete class, it's depending on a guaranteed capability rather than a specific implementation.
This creates the conditions for polymorphism:
1. Substitutability: Any implementation of the interface can be substituted for any other. Client code treats all implementations uniformly because they all fulfill the same contract.
2. Extensibility: New implementations can be added without modifying existing code. The new class simply signs the same contract.
3. Testability: Test implementations (mocks, stubs, fakes) can be substituted for production implementations because tests can provide their own contract fulfillment.
4. Decoupling: Client code is insulated from implementation changes. As long as the contract is maintained, internal implementations can evolve freely.
| Aspect | Direct Class Dependency | Interface Contract Dependency |
|---|---|---|
| Coupling | Tight coupling to specific implementation | Loose coupling to behavioral contract |
| Substitutability | Cannot substitute different implementations | Any implementation freely substitutable |
| Testing | Must use production classes or invasive mocking | Easy injection of test doubles |
| Extensibility | Changes require modifying client code | New implementations added without changes |
| Flexibility | Locked to one behavior | Behavior varies by implementation |
| Maintenance | Ripple effects from implementation changes | Isolated changes within implementations |
Consider what happens when you depend directly on StripeProcessor class versus the PaymentProcessor interface:
Direct Dependency on StripeProcessor:
Contract Dependency on PaymentProcessor:
MockProcessor implementing the interfaceThis is the transformative power of programming to interfaces: your code becomes oblivious to implementation details while remaining fully functional.
When you depend on interfaces instead of concrete classes, you're inverting the traditional dependency direction. Instead of high-level code depending on low-level implementation details, both depend on the abstraction (interface). This is the Dependency Inversion Principle in action—one of the most important principles in software architecture.
Not all interfaces are created equal. A well-designed interface is like a well-drafted legal contract: clear, focused, and enforceable. Let's examine the characteristics that distinguish excellent interface design from poor design.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// ❌ POOR: Fat interface with unrelated responsibilitiesinterface BadUserService { // User CRUD operations createUser(data: UserData): User; getUser(id: string): User; updateUser(id: string, data: UserData): User; deleteUser(id: string): void; // Authentication - different responsibility! login(email: string, password: string): Session; logout(sessionId: string): void; // Email - different responsibility! sendWelcomeEmail(user: User): void; sendPasswordReset(email: string): void; // Database-specific - implementation leak! beginTransaction(): void; commitTransaction(): void; rollbackTransaction(): void;} // ✅ GOOD: Separate, focused interfacesinterface UserRepository { create(data: UserData): User; findById(id: string): User | null; update(id: string, data: Partial<UserData>): User; delete(id: string): void;} interface Authenticator { login(credentials: Credentials): Session; logout(sessionId: string): void; validateSession(sessionId: string): boolean;} interface NotificationSender { send(recipient: string, message: Message): void;} // Each interface:// - Has a single, clear purpose// - Contains only related methods// - Makes no implementation assumptions// - Is easy to implement and test // Classes can implement multiple focused interfaces as neededclass UserService implements UserRepository { create(data: UserData): User { /* ... */ } findById(id: string): User | null { /* ... */ } update(id: string, data: Partial<UserData>): User { /* ... */ } delete(id: string): void { /* ... */ }} class AuthService implements Authenticator { login(credentials: Credentials): Session { /* ... */ } logout(sessionId: string): void { /* ... */ } validateSession(sessionId: string): boolean { /* ... */ }}The SOLID principles include the Interface Segregation Principle (ISP), which states:
"Clients should not be forced to depend on interfaces they do not use."
This principle captures a critical insight about interface design: large, general-purpose interfaces force implementers to provide functionality they may not need, and force clients to depend on methods they never call.
Consider what happens when you have a "god interface" with 20 methods:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
// ❌ Violates Interface Segregation Principleinterface Machine { print(document: Document): void; scan(): Image; fax(document: Document, number: string): void; copy(document: Document): void; staple(documents: Document[]): void;} // Problem: A simple printer must implement fax() and scan()class SimplePrinter implements Machine { print(document: Document): void { // This is what we actually do } scan(): Image { throw new Error("SimplePrinter cannot scan!"); } fax(document: Document, number: string): void { throw new Error("SimplePrinter cannot fax!"); } copy(document: Document): void { throw new Error("SimplePrinter cannot copy!"); } staple(documents: Document[]): void { throw new Error("SimplePrinter cannot staple!"); }} // ✅ Follows Interface Segregation Principleinterface Printer { print(document: Document): void;} interface Scanner { scan(): Image;} interface Fax { fax(document: Document, number: string): void;} // Simple printer only implements what it can doclass SimplePrinter implements Printer { print(document: Document): void { console.log("Printing document..."); }} // Multi-function device implements multiple interfacesclass MultiFunctionDevice implements Printer, Scanner, Fax { print(document: Document): void { console.log("Printing document..."); } scan(): Image { console.log("Scanning..."); return new Image(); } fax(document: Document, number: string): void { console.log(`Faxing to ${number}...`); }} // Client code can depend on exactly what it needsfunction printReport(printer: Printer, report: Document) { // Only depends on Printer, doesn't care about scan/fax capability printer.print(report);} // Works with SimplePrinter (just a printer)// Also works with MultiFunctionDevice (printer + more)printReport(new SimplePrinter(), report);printReport(new MultiFunctionDevice(), report);When you segregate interfaces by capability, you create building blocks that can be composed freely. A class can implement exactly the interfaces that match its actual capabilities, and clients can depend only on the capabilities they need. This principle will be explored in depth in the SOLID principles chapter.
In TypeScript, you might wonder about the difference between interface and type since both can define object shapes. While they share many capabilities, they have distinct purposes and trade-offs:
| Aspect | Interface | Type Alias |
|---|---|---|
| Primary Purpose | Define object shapes and contracts | Name any type (objects, unions, primitives, etc.) |
| Extension | Uses extends keyword, can extend multiple | Uses intersections (&) for composition |
| Declaration Merging | Multiple declarations merge into one | Cannot be merged, must be unique |
| Class Implementation | Classes can implement interfaces | Classes can implement type aliases (if object types) |
| Computed Properties | Cannot use computed properties | Can use in for mapped types |
| Unions | Cannot represent union types directly | Can represent union types naturally |
| Best For | Object shapes, class contracts, public APIs | Complex types, unions, mapped types |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// INTERFACE: Best for object shapes and contractsinterface User { id: string; name: string; email: string;} // Declaration merging - interfaces can be extended across filesinterface User { age?: number; // This MERGES with the above definition} // Extending interfacesinterface Admin extends User { permissions: string[];} // TYPE ALIAS: Best for complex types, unions, utilitiestype Status = 'active' | 'inactive' | 'pending'; // Union type type Nullable<T> = T | null; // Generic type alias type UserOrAdmin = User | Admin; // Union of interfaces // Intersection types with type aliasestype UserWithMetadata = User & { createdAt: Date; updatedAt: Date;}; // Mapped types (only possible with type aliases)type Readonly<T> = { readonly [P in keyof T]: T[P];}; type PartialUser = Partial<User>; // RECOMMENDATION:// - Use INTERFACES for defining the shapes of objects that // classes will implement (contracts)// - Use TYPE ALIASES for:// - Union types// - Intersection types // - Mapped/conditional types// - Aliasing primitives or tuples // For polymorphism and contracts, interfaces are generally preferredinterface Repository<T> { findById(id: string): T | null; save(entity: T): T; delete(id: string): void;} class UserRepository implements Repository<User> { findById(id: string): User | null { /* ... */ } save(entity: User): User { /* ... */ } delete(id: string): void { /* ... */ }}For defining behavioral contracts that classes will implement—the topic of this module—use interfaces. They communicate intent more clearly ('this is a contract that must be fulfilled') and support declaration merging which can be valuable for extending third-party types. Use type aliases when you need unions, mapped types, or other advanced type operations.
Let's examine interfaces from real-world systems to see how contracts enable flexible, maintainable designs. These examples illustrate how interfaces solve genuine engineering problems.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// EXAMPLE 1: Logger Interface// Multiple logging implementations (console, file, cloud service)// can be swapped without changing application code interface Logger { debug(message: string, context?: object): void; info(message: string, context?: object): void; warn(message: string, context?: object): void; error(message: string, error?: Error, context?: object): void;} class ConsoleLogger implements Logger { debug(message: string, context?: object): void { console.debug(`[DEBUG] ${message}`, context); } info(message: string, context?: object): void { console.info(`[INFO] ${message}`, context); } warn(message: string, context?: object): void { console.warn(`[WARN] ${message}`, context); } error(message: string, error?: Error, context?: object): void { console.error(`[ERROR] ${message}`, error, context); }} class CloudWatchLogger implements Logger { // Sends logs to AWS CloudWatch instead of console // Same interface, completely different implementation debug(message: string, context?: object): void { /* AWS API calls */ } info(message: string, context?: object): void { /* AWS API calls */ } warn(message: string, context?: object): void { /* AWS API calls */ } error(message: string, error?: Error, context?: object): void { /* AWS API calls */ }} // EXAMPLE 2: Event Emitter Interface// Different event systems can be substituted interface EventEmitter<Events extends Record<string, unknown>> { on<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void; off<K extends keyof Events>(event: K, handler: (data: Events[K]) => void): void; emit<K extends keyof Events>(event: K, data: Events[K]): void;} // EXAMPLE 3: Cache Interface// In-memory, Redis, Memcached - all implement the same contract interface Cache<T> { get(key: string): Promise<T | null>; set(key: string, value: T, ttlSeconds?: number): Promise<void>; delete(key: string): Promise<boolean>; has(key: string): Promise<boolean>; clear(): Promise<void>;} class InMemoryCache<T> implements Cache<T> { private store = new Map<string, { value: T; expiry?: number }>(); async get(key: string): Promise<T | null> { const item = this.store.get(key); if (!item) return null; if (item.expiry && Date.now() > item.expiry) { this.store.delete(key); return null; } return item.value; } async set(key: string, value: T, ttlSeconds?: number): Promise<void> { this.store.set(key, { value, expiry: ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined }); } async delete(key: string): Promise<boolean> { return this.store.delete(key); } async has(key: string): Promise<boolean> { return this.store.has(key); } async clear(): Promise<void> { this.store.clear(); }} class RedisCache<T> implements Cache<T> { // Connects to Redis instead of in-memory storage // Same interface, distributed caching capability async get(key: string): Promise<T | null> { /* Redis calls */ } async set(key: string, value: T, ttlSeconds?: number): Promise<void> { /* Redis calls */ } async delete(key: string): Promise<boolean> { /* Redis calls */ } async has(key: string): Promise<boolean> { /* Redis calls */ } async clear(): Promise<void> { /* Redis calls */ }} // Application code works with any cache implementationclass ProductService { constructor(private cache: Cache<Product>) {} async getProduct(id: string): Promise<Product | null> { // Check cache first const cached = await this.cache.get(id); if (cached) return cached; // Fetch from database const product = await this.fetchFromDatabase(id); if (product) { await this.cache.set(id, product, 3600); // 1 hour TTL } return product; }}We've explored the foundational concept of interfaces as contracts—the mechanism that enables the most powerful and flexible form of polymorphism in object-oriented design.
What's Next:
Now that we understand interfaces as contracts, we'll explore one of their most powerful capabilities: implementing multiple interfaces. Unlike single inheritance, a class can fulfill many interface contracts simultaneously, enabling rich polymorphic behavior that would be impossible with class inheritance alone.
You now understand the fundamental concept of interfaces as behavioral contracts. This understanding is essential for everything that follows—from multiple interface implementation to programming-to-interfaces principles. The contract metaphor will guide you in designing flexible, maintainable systems.