Home / Articles

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.

Publisher: REACTAPP.TOP7 min read1599 words

This article is published in English.

Sources: Day 1: (Enums and Interfaces) LLD With TS | Part 2

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 type for composition (unions, intersections, mapped types) and interface for contracts that classes implement.
  • Interfaces describe what an object provides, never how, which enables interchangeable implementations.
  • TypeScript checks shapes structurally, so implements is optional, but it moves contract errors to the class definition where they are easiest to fix.