TypeScript Interface vs. Class: One Interface, Two Implementations, One Class
This codebase has one interface with two implementations — the textbook case for a class — and reaches for a class exactly once, in a test file.
TypeScript interfaces describe a shape and vanish at compile time; classes describe a shape and carry an implementation, and the compiler leaves them in the output JavaScript verbatim. That difference is not academic here — this codebase has exactly one interface with two implementations, the textbook case every guide says to reach for a class, and it reaches for a class exactly once, inside a test file, for reasons the interface itself explains.
The short answer
An interface in TypeScript only exists during type-checking — it describes what shape a value must have and disappears the moment tsc finishes. A class does that too, but it also generates real runtime JavaScript: a constructor function, a prototype, methods. Use an interface when you're describing a contract that something else will satisfy — a function's return shape, a dependency another module can swap out. Reach for a class only when you need to construct instances that carry their own state and behavior together. This repo has 41 interface declarations across 27 files and exactly one class in its entire source tree — and that one class exists only inside a test.
The comparison that matters
interface | class | |
|---|---|---|
| Exists at runtime | No — fully erased | Yes — emits a constructor and prototype |
| Can be implemented by multiple things | Yes — any object or class matching the shape | N/A — a class is one implementation |
| Can hold private state | No — it has no instances of its own | Yes — fields, private members, methods |
| Declaration merging | Yes — two interface Foo blocks combine | No — a second class Foo is a redeclaration error |
| Cost in the compiled bundle | Zero | One constructor + prototype chain, however small |
| What replaces it here | A factory function returning an object literal | Nothing — this codebase has one, in a test double |
The "can be implemented by multiple things" row is the one that matters for this comparison, because it's the one place classes and interfaces genuinely compete: TypeScript lets a class declare implements SomeInterface, and plenty of guides frame that as the reason to reach for a class — "if your entity has behavior, use a class; the class implementing the interface needs to strictly conform to its structure." This repo has precisely that shape once, and what it does with it is the interesting part.
The one interface with two implementations
src/lib/lemonsqueezy/webhook.ts defines the operations the LemonSqueezy webhook handler needs from a database, as a narrow interface rather than a raw Supabase client:
// src/lib/lemonsqueezy/webhook.ts
export interface WebhookDb {
createOrderWithSlots(
order: NewOrderInput,
slotKinds: string[],
): Promise<{ orderId: string; created: boolean }>;
refundOrder(lsOrderId: string): Promise<boolean>;
}
Two methods, both async, both mapping directly onto the atomic Postgres RPCs (create_order_with_slots, refund_order) that back real order and refund processing. The comment above it in the source spells out why the interface is narrow rather than "just pass the Supabase client": it makes processWebhook unit-testable with an in-memory fake, no live database required.
That gives WebhookDb two implementations — textbook grounds, per every SERP result for this keyword, for at least one of them to be a class. The production one isn't:
// src/lib/lemonsqueezy/webhookDb.ts
export function createSupabaseWebhookDb(client: SupabaseClient): WebhookDb {
return {
async createOrderWithSlots(order: NewOrderInput, slotKinds: string[]) {
const { data, error } = await client
.rpc("create_order_with_slots", { /* ...params */ })
.single();
if (error) throw error;
const row = data as { order_id: string; created: boolean };
return { orderId: row.order_id, created: row.created };
},
async refundOrder(lsOrderId: string) {
const { data, error } = await client.rpc("refund_order", {
p_ls_order_id: lsOrderId,
});
if (error) throw error;
return Boolean(data);
},
};
}
A factory function that closes over client and returns a plain object literal satisfying WebhookDb. No class, no this, no constructor. The object it returns is the implementation — TypeScript's structural typing means the object doesn't need to say implements WebhookDb anywhere for the assignment to type-check; the return type annotation on the function is the only place WebhookDb appears.
The one class in this codebase, and why it's the exception that proves the rule
The second WebhookDb implementation lives in the test suite, and it's the only class declaration anywhere in src/:
// src/lib/__tests__/webhook.test.ts
class FakeWebhookDb implements WebhookDb {
orders = new Map<
string,
{ id: string; status: "paid" | "refunded"; userId: string | null }
>();
slots: {
id: string;
orderId: string;
kind: SlotKind;
userId: string | null;
claimEmail: string;
status: "active" | "revoked";
}[] = [];
private nextId = 1;
async createOrderWithSlots(order: NewOrderInput, slotKinds: string[]) {
// ...mirrors create_order_with_slots' on-conflict + FK-violation behavior
}
async refundOrder(lsOrderId: string) {
// ...unconditionally re-revokes every non-revoked slot, mirroring the RPC
}
}
This is the one spot in the repo where a class genuinely earns its place: FakeWebhookDb needs mutable private state (orders, slots, a private nextId counter for generated IDs) that persists across multiple calls within one test, and a private field is a real feature classes have that a returned object literal doesn't get for free. It's also the one file where implements WebhookDb is written explicitly — because a test double is exactly the case the "multiple implementations" argument is describing.
So the full inventory is: one interface, two implementations, and the class-vs-factory-function choice split exactly along the line you'd predict from the table above. The production implementation has no private state to hide and no reason to construct more than one instance — a closure and a returned object cover it completely. The test double needs encapsulated, mutable state across calls, which is what pushed it to a class. Nothing here was decided by a style guide; it fell out of which side of the interface needed to hide data from the other.
Interfaces without a second implementation
The other 40 interfaces in this codebase don't have a second implementation at all — they're shape contracts for data, not seams for swappable behavior. NewOrderInput, ProcessWebhookResult, and ProcessWebhookDeps (also in webhook.ts) each describe exactly one thing: what an order payload looks like, what a webhook response looks like, what dependencies processWebhook needs injected. None of them has a class anywhere near it, because none of them needs an instance — they type plain objects that get created with object literals and never call a method on themselves.
That's also why the 41 type aliases in this codebase (across 31 files) coexist with interface rather than replacing it: the distinction the SERP results converge on — "interface for object shapes that might be extended or implemented, type alias for unions, tuples, and everything a class can't be" — holds up against a full read of this tree. SlotKind, the "active" | "revoked" union on FakeWebhookDb.slots, and Tier are all type, because a union of string literals isn't a shape a class or an implements clause could express in the first place.
Interfaces erase; classes don't — verified, not assumed
The claim that interfaces disappear at compile time is easy to state and easy to under-trust, so it's worth checking against this repo's own installed TypeScript rather than taking any guide's word for it. Compiling a two-line probe — an interface, a class implementing it, nothing else — through this project's typescript package produces:
class Circle {
constructor(radius) {
this.radius = radius;
}
area() {
return Math.PI * this.radius ** 2;
}
}
function describe(s) {
return s.area();
}
describe(new Circle(2));
The Shape interface that Circle implemented is gone completely — not renamed, not commented out, absent. Circle survives intact as a real ES class. That's the entire cost model in one compiled file: an interface is free at runtime because there is no runtime trace of it left to pay for.
Mistakes and troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| "Class 'X' incorrectly implements interface 'Y'" | The class is missing a method the interface requires, or a method's signature doesn't match | Implement every member the interface declares with a compatible signature — TypeScript checks structurally, not by name matching alone |
A factory-function object satisfies an interface with no implements keyword anywhere | This is expected — TypeScript's structural typing checks shape, not a declared relationship, unlike Java or C# | Add the return type annotation (as createSupabaseWebhookDb does) if you want the compiler to catch a missing method at the function definition, not at the call site |
Two interface Foo declarations in the same scope merge instead of erroring | Declaration merging is a deliberate interface-only feature, used by libraries to extend ambient types | If merging was accidental, rename one — a type alias would have caught this as a duplicate-identifier error instead |
A private field needed inside a WebhookDb-shaped object, but object literals can't have private | Object literals have no access-modifier syntax at all | Reach for a class (as FakeWebhookDb does) or close over the value in the factory function's outer scope instead of storing it on the object |
| An interface member typed as a method never gets called correctly on a plain data object | The interface was written for a service/dependency shape, not a data shape | Data contracts should default to type; reserve interface for things another module implements or extends |
Frequently asked questions
Can a type alias do everything an interface can?
Almost — a type alias can describe object shapes too, and this codebase's own split shows the practical rule: interface for anything with multiple implementations or that might be extended (WebhookDb, ProcessWebhookDeps), type for unions, and plain data shapes where either would work. The one thing type can't do that interface can is declaration merging, and the one thing interface can't do that type can is describe a union like SlotKind.
Does implementing an interface make something a class?
No. TypeScript's structural typing means any object with the right shape satisfies an interface, implements keyword or not — createSupabaseWebhookDb's returned object literal satisfies WebhookDb without ever writing implements anywhere. The keyword is documentation for humans and an extra compile-time check; the compiler would accept the object either way.
If interfaces are free at runtime, why not use them for everything, including behavior?
Because an interface can't hold an implementation or private state — it's a contract, not a container. The moment something needs to remember state across method calls and keep part of that state hidden, as FakeWebhookDb does with its private nextId counter, you need an actual instance, which means a class (or a closure).
Is one class in an entire production codebase unusual? For a codebase built on function components and factory functions by default, no — it's the expected shape. React itself pushed the ecosystem toward functions over classes years ago, and the same instinct extends to services: a function returning an object is usually simpler to test and compose than a class, right up until something needs private state across calls, which is the one condition this repo's single class actually meets.
Templates in this post
ASoc Reach markets an AI marketing agency with a performance-analytics hero, an eight-item AI services grid, and a project showcase. ASoc Realm is a property-management SaaS site built around an occupancy-metrics hero and a features grid spanning smart scheduling, maintenance tracking, and inspections. ASoc Relay markets a team-messaging platform with a live chat-widget hero and five solution blocks covering support, team comms, AI chatbots, and omnichannel reach.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
