Library
10 min read

Abstraction vs interface vs abstract class

Published

These three get compared as if they were three options on the same menu. They are not. One of them is an idea, and the other two are ways of writing that idea down.

Abstraction is the idea: show the caller what something can do and hide how it does it. The switch on the wall from the abstraction chapter is an abstraction. You press it and the light comes on, whether the power is from the grid, the inverter or the solar panel on the roof.

An interface and an abstract class are two TypeScript tools for building that switch. Think of a restaurant chain. The menu card is an interface: it lists what every outlet must be able to serve, and says nothing about recipes. The franchise kitchen manual is an abstract class: steps one to three are written out and every outlet follows them exactly, and step four says "your local special goes here".

Your answer

You have three payment gateways. All three must reject an amount of zero or less, then each talks to its own API in its own way. Would you write the shared contract as an interface or an abstract class? What makes you pick one?

Abstraction does not need either keyword#

Before the two tools, notice that you can have abstraction with neither of them. This type alias is an abstraction too. Checkout depends on "something that can send a message to a phone" and knows nothing about SMS vendors:

ts
type Notify = (phone: string, text: string) => string
 
function placeOrder(notify: Notify) {
  return notify('9810012345', 'Order 42 confirmed')
}
 
const sms: Notify = (phone, text) => `SMS to ${phone}: ${text}`
console.log(placeOrder(sms)) // SMS to 9810012345: Order 42 confirmed

So the useful question is never "abstraction or interface?". It is "I want an abstraction here. Which tool do I write it with?" A function type for one operation, an interface for a set of operations, an abstract class when the implementations share real code.

Interface: the what, and nothing else#

An interface is a list of members a thing must have. It has no code in it, no fields with values, and no constructor.

ts
interface PaymentGateway {
  charge(rupees: number): string
}
 
class Razorpay implements PaymentGateway {
  charge(rupees: number) {
    return `razorpay charged ${rupees}`
  }
}
 
class Paytm implements PaymentGateway {}
// Error: Class 'Paytm' incorrectly implements interface 'PaymentGateway'.
 
function checkout(gateway: PaymentGateway) {
  return gateway.charge(499)
}
 
console.log(checkout(new Razorpay())) // razorpay charged 499

implements asks the compiler to check the class against the contract, which is why the empty Paytm is rejected. There is something more surprising, and it matters every day in TypeScript: the check is about shape, not names. Anything with a charge(rupees) method that returns a string is a PaymentGateway, whether or not it says implements.

ts
interface PaymentGateway {
  charge(rupees: number): string
}
 
function checkout(gateway: PaymentGateway) {
  return gateway.charge(499)
}
 
// No class and no implements. It has the right shape, so it fits.
const fakeGateway = { charge: (rupees: number) => `fake ${rupees}` }
 
console.log(checkout(fakeGateway)) // fake 499

That is called structural typing, and it is why interfaces make testing cheap in TypeScript: a test fake is a one line object. Java and C# do not work like this. There, a class only counts as an interface if it says so.

Abstract class: the what, plus part of the how#

An abstract class is a class you are not allowed to create directly. It can hold real methods, fields and a constructor, and it can mark some methods abstract, meaning "every child must write this one".

ts
abstract class Gateway {
  charge(rupees: number): string {
    if (rupees <= 0) throw new Error('bad amount')
    return this.send(rupees)
  }
  protected abstract send(rupees: number): string
}
 
class Razorpay extends Gateway {
  protected send(rupees: number) {
    return `razorpay charged ${rupees}`
  }
}
 
console.log(new Razorpay().charge(499)) // razorpay charged 499
 
const g = new Gateway()
// Error: Cannot create an instance of an abstract class

The amount check now lives in one place. Each gateway only writes send, the one step that is actually different. The parent decides the order of the steps, and the child fills a hole. (That shape has its own name, the template method pattern, which comes up again in strategy vs template method vs command.)

What is left after it compiles#

This is the difference people forget, and the one that explains most of the others. TypeScript's types are checked and then thrown away: the browser and Node run plain JavaScript. Switch between the two contracts and the two views below.

What you write, and what actually runs
interface PaymentGateway {
  charge(rupees: number): string
}

class Razorpay implements PaymentGateway {
  charge(rupees: number) {
    if (rupees <= 0) throw new Error('bad amount')
    return `razorpay: ${rupees}`
  }
}

class Paytm implements PaymentGateway {
  charge(rupees: number) {
    if (rupees <= 0) throw new Error('bad amount')
    return `paytm: ${rupees}`
  }
}
The amount check is written
twice, once per gateway
After compiling, PaymentGateway is
gone. Not one line of it runs.
x instanceof PaymentGateway
a compile error: it is only a type
A class can take on
as many interfaces as it likes
An interface says what a gateway can do and nothing about how. Both classes have to write the amount check themselves, so it is written twice. Switch to the compiled JavaScript to see what is left.

An interface has no runtime existence at all. So you cannot import it into plain JavaScript, you cannot check x instanceof PaymentGateway, and it costs zero bytes in your bundle. An abstract class is a real class that ships, so instanceof works and its shared code runs. The abstract keyword itself is erased, though. Only TypeScript stops new Gateway().

ts
interface PaymentGateway {
  charge(rupees: number): string
}
 
const x: unknown = { charge: () => 'ok' }
const ok = x instanceof PaymentGateway
// Error: 'PaymentGateway' only refers to a type, but is being used
// as a value here.

The differences that matter in practice#

InterfaceAbstract class
Holds code, fields, a constructorNoYes
How many can one class take onAny number: implements A, B, COne: extends takes a single class
Exists at runtimeNo, erasedYes, a real class
instanceofNot possibleWorks
A plain object can satisfy itYes, by shapeOnly if the class has no private or protected members
Adding a method laterEvery implementer breaks until it adds itAdd it with a default body, nobody breaks

The last row is the one teams find out about the hard way. Add refund() to a PaymentGateway interface with nine implementations and nine classes stop compiling. Add it to an abstract class with a sensible default and none of them do.

You can have both#

The answer most senior engineers give in practice: callers depend on the interface, and the abstract class is an optional helper for the people writing implementations.

ts
interface PaymentGateway {
  charge(rupees: number): string
}
 
abstract class BaseGateway implements PaymentGateway {
  charge(rupees: number): string {
    if (rupees <= 0) throw new Error('bad amount')
    return this.send(rupees)
  }
  protected abstract send(rupees: number): string
}
 
class Razorpay extends BaseGateway {
  protected send(rupees: number) {
    return `razorpay ${rupees}`
  }
}
 
// A test fake skips the base class. It only needs the shape.
const fake: PaymentGateway = { charge: (r) => `fake ${r}` }
 
function checkout(gateway: PaymentGateway) {
  return gateway.charge(250)
}
 
console.log(checkout(new Razorpay())) // razorpay 250
console.log(checkout(fake)) // fake 250

Checkout never names BaseGateway. A new gateway can use the base class to get the amount check for free, or skip it, and a test can pass a one line object.

An abstract class with nothing but abstract methods

If every method in your abstract class is abstract, it is an interface wearing a costume. You have paid for it (one parent per class, a real class in the bundle) and got nothing back. Make it an interface. The opposite trap is a five level extends chain built to share three lines of code: share them with a function or a small helper object instead.

ChoiceWhat you gainWhat you payPick it when
Function typeThe smallest possible contract. Any function with the right signature fits.One operation only. Awkward once the thing needs three methods and some state.A callback, a single strategy, a notifier with one method.
InterfaceZero runtime cost, any number per class, fakes are plain objects.No shared code, and adding a member breaks every implementation.The default for any contract between parts of your code.
Abstract classShared code and a fixed order of steps live in one place.A class can extend only one, and children are tied to the parent's internals.Several implementations genuinely repeat the same logic around one varying step.
TypeScript you just picked up
type Notify = (phone: string) => string
A type alias for a function. Any function with this signature fits.
interface X { charge(n: number): string }
A contract: members a thing must have, and no code.
class A implements X, Y
Asks the compiler to check A against X and Y. A class can implement many.
abstract class Base { ... }
A class that cannot be created with new. It can still hold real code.
protected abstract send(): string
Every child must write send. protected means only the class and its children can call it.
class A extends Base
Inherit from one class. Only one extends is allowed.
const x: unknown
A value of a type you know nothing about yet. You have to check it before using it.

In the interview#

Interviewer
What is the difference between abstraction and an interface?
A classic opener. The trap is answering as if they were two alternatives.
You
Abstraction is the design idea: callers see what something does and not how. An interface is one way to express it, a contract with no implementation. An abstract class is another way that can also carry shared code. Even a function type is an abstraction. So I would say abstraction is the goal, and interface or abstract class is the tool I pick to write it down.
Separates idea from tool in the first sentence.
Interviewer
When would you choose an abstract class over an interface?
You
When the implementations would otherwise copy the same code, like every payment gateway validating the amount and logging the same way around one step that differs. Even then I usually keep an interface for callers and make the abstract class an optional base for implementers, so tests can pass a plain object. In TypeScript there is one more reason to prefer interfaces: they are erased at compile time and a class can implement many, while it can only extend one class.
Gives a concrete trigger, then the both-at-once answer, then a TypeScript specific detail.
Which tool?
Checkout needs "something that can charge money", and tests should pass a fake in one line
Five report exporters all open a file, write a header, write rows their own way, then close the file
A sort function needs a way to compare two orders
A class must be both Printable and Serializable

Checkpoint#

Checkpoint

1. Which statement is accurate?

2. What is left of a TypeScript interface in the compiled JavaScript?

3. Nine classes implement a PaymentGateway interface. You add refund() to it. What happens?

Worth memorising
  • Abstraction is the idea. Interface and abstract class are tools for it.
  • Interface: what, no how. Erased at runtime. Implement as many as you like.
  • Abstract class: what plus some how. Real at runtime. Extend only one.
  • TypeScript checks shape, not names. Any object with the right members fits an interface.
  • Default to an interface. Add an abstract base only when implementers repeat real code.
Say this in 60 seconds

Abstraction is the design idea of showing callers what something does and hiding how. An interface and an abstract class are two ways to write it down, and a plain function type is a third. An interface is a pure contract: no code, no fields, any number per class, and in TypeScript it is erased at compile time, so it costs nothing at runtime and cannot be used with instanceof. TypeScript also checks interfaces by shape, so any object with the right methods fits, which makes test fakes one line. An abstract class can carry shared code and a fixed order of steps, with abstract methods for the parts each child fills in, but a class can extend only one and it ships as a real class. My default is an interface for callers, plus an optional abstract base for implementers when they would otherwise copy the same code.