TypeScript Enums and Interfaces as Building Blocks for Low-Level Design
How TypeScript enums remove magic-string bugs, when to choose type or interface, and how interfaces, implements and structural typing define contracts in low-level design.
This article is published in English.
After classes and objects, the next tools for low-level design (LLD) in TypeScript are enums and interfaces. They look trivial, yet they keep a design consistent: enums pin down allowed values, interfaces pin down object contracts. This article shows the bugs each prevents, how to choose between type and interface, and what structural typing means for implements.
The problem enums solve: scattered magic strings
Suppose an order in your application can be in one of three states:
PENDING
SUCCESS
REJECTED
Code in one module checks for a pending order like this:
if (order.status === "PENDING") {
// ...
}
Another module checks for success:
if (order.status === "SUCCESS") {
// ...
}
Everything works until a new teammate writes a check that looks almost identical but uses lowercase:
if (order.status === "pending") {
// ...
}
String comparison is case-sensitive, so these are different values:
"PENDING" !== "pending"
The condition is silently never true. The root cause is hand-typed values everywhere: who remembers whether it was PENDING, pending or Pending?
Defining the allowed values once
An enum declares a fixed set of named values in one place so the rest of the code can refer to them by name. Here each member is explicitly given its string value:
enum OrderStatus {
PENDING = "PENDING",
SUCCESS = "SUCCESS",
REJECTED = "REJECTED"
}
Instead of comparing against a raw string, as in this check,
if (order.status === "PENDING") {
// ...
}
you compare against the enum member:
if (order.status === OrderStatus.PENDING) {
// ...
}
The same applies to every other status check:
if (order.status === OrderStatus.SUCCESS) {
// ...
}
Nobody has to recall the exact spelling, only the member name, which the editor autocompletes:
OrderStatus.PENDING
A typo in a member name is now a compile-time error rather than a silent runtime mismatch.
Why this matters as the codebase grows
Imagine the status appears in twenty places. Without an enum, consistency depends on discipline, and variations creep in:
"PENDING"
"PENDING"
"pending"
"Pending"
"PENDING"
With an enum, every location refers to the same declaration:
OrderStatus.PENDING
In short, an enum is a named, predefined set of constants.
String enums are easier to debug than numeric ones because values stay readable in logs. A union of string literals, shown next, gives similar safety with no runtime code, which some teams prefer.
type versus interface
For LLD and interviews, a simple mental model of the two is enough.
type: composing and modelling types
- Flexible for combining types.
- Handles unions, intersections, tuples, primitives, mapped types and more.
- A good fit when you are mainly describing or composing data.
A union of literal strings is a typical example:
type Status = "PENDING" | "SUCCESS" | "FAILED";
So is an identifier that may be either a string or a number:
type ID = string | number;
interface: contracts for objects and classes
- Primarily describes the shape of objects and the contract classes must meet.
- Can be implemented by a class with
implements. - A good fit when several implementations must honour the same contract.
- Supports declaration merging, so two declarations with the same name combine into one.
interface Payment {
amount: number;
pay(): void;
}
A rule of thumb
When you need to compose or model a type, reach for type:
Need to compose/model a type?
↓
type
When you need a contract between classes or objects, reach for interface:
Need a contract between classes/objects?
↓
interface
It is a guideline, not a law: both can describe object shapes.
Interfaces describe object structure
The easiest way into interfaces is to think about the structure of an object. Here is a plain JavaScript object:
const student = {
id: 101,
name: "Rahul",
age: 20
};
TypeScript lets you describe that shape, for instance with a type alias:
type Student = {
id: number;
name: string;
age: number;
};
Several objects can then be checked against the same shape:
const student1: Student = {
id: 101,
name: "Rahul",
age: 20
};
const student2: Student = {
id: 102,
name: "Aman",
age: 21
};
As the app grows, naming that structure as a reusable contract is what an interface is for.
An interface as a contract
The same shape expressed as an interface:
interface Student {
id: number;
name: string;
age: number;
}
Any value that should be treated as a Student must now satisfy it:
const student: Student = {
id: 101,
name: "Rahul",
age: 20
};
Leave out a required property, as here,
const student: Student = {
id: 101,
name: "Rahul"
};
and the compiler reports an error because this property is missing:
age
In effect, the interface says: to be treated as this type, you must meet this contract.
Interfaces define what, not how
This distinction is central to LLD. An interface states what something must look like or provide, not how it does it. Take a payment contract:
interface Payment {
amount: number;
pay(): void;
}
It guarantees exactly two things:
Payment must have:
→ amount
→ pay()
It says nothing about how pay() works. One class might process a card, starting with the required amount property,
class CreditCardPayment implements Payment {
amount: number;
and supplying its own pay():
pay() {
console.log("Paying using credit card");
}
}
Another might pay through UPI, India's instant payments system, with the same property,
class UPIPayment implements Payment {
amount: number;
and a different pay():
pay() {
console.log("Paying using UPI");
}
}
Same contract, different implementations, so callers can work with any payment:
Interface
↓
Defines the contract
↓
Multiple implementations
↓
Different implementation details
The implements keyword
implements makes the relationship explicit. Given the contract,
interface Payment {
amount: number;
pay(): void;
}
a class declares that it satisfies it:
class CreditCardPayment implements Payment {
amount: number; pay() {
console.log("Paying with credit card");
}
}
The clause
implements Payment
asks the compiler to verify that the class meets the Payment contract. If it does not, for example because pay() is missing,
class CreditCardPayment implements Payment {
amount: number;
}
the compiler flags the absent
pay()
method. implements is purely a compile-time check; it adds nothing at runtime. One detail when trying these snippets: with strictPropertyInitialization enabled, a declared field such as amount: number; without an initializer or constructor assignment is itself an error, so give it a value.
Structural typing: shape beats declarations
Unlike Java or C#, TypeScript is structurally typed. A class does not have to declare
implements Payment
to be accepted where a Payment is expected. Consider a contract with a single method:
interface Payment {
pay(): void;
}
and a class that never mentions it:
class UPIPayment {
pay() {
console.log("Paying through UPI");
}
}
Even without writing
implements Payment
a function that expects a Payment
function processPayment(payment: Payment) {
payment.pay();
}
happily accepts an instance of that class:
const payment = new UPIPayment();processPayment(payment);
This compiles because UPIPayment has the required shape:
{
pay(): void;
}
For TypeScript, matching the shape is enough; the explicit declaration is optional.
So why write implements at all?
If structural typing already accepts matching classes, implements might seem redundant. Its value is that it checks the contract where the class is defined, not only where the class happens to be used. Given the contract
interface Payment {
amount: number;
pay(): void;
}
and a class that explicitly implements it,
class UPIPayment implements Payment {
amount = 100; pay() {
console.log("Paying through UPI");
}
}
suppose someone later extends the interface with a currency:
interface Payment {
amount: number;
currency: string;
pay(): void;
}
The compiler immediately reports that UPIPayment no longer satisfies the contract, pointing at the class itself. Without it, the error would appear only at some distant call site, or not at all.
Key takeaways
- Enums replace scattered magic strings with one declaration, turning typos into compile errors.
- String literal unions are a lightweight alternative when you do not need a runtime object.
- Use
typefor composition (unions, intersections, mapped types) andinterfacefor contracts that classes implement. - Interfaces describe what an object provides, never how, which enables interchangeable implementations.
- TypeScript checks shapes structurally, so
implementsis optional, but it moves contract errors to the class definition where they are easiest to fix.