Abstraction in JavaScript
Enforcing Contracts Without Native Support
LinkedIn Hook
Java has
abstract class. C# hasinterface. Python hasABC. JavaScript has... nothing.No
abstractkeyword. Nointerfacekeyword. No compile-time enforcement. You can instantiate any class you write, call any method that doesn't exist, and JavaScript will wait patiently until runtime to tell you something went wrong.Yet every serious JavaScript codebase I've worked in has abstraction. Not because the language forces it — but because the developers built the enforcement themselves.
This is the lesson most OOP courses skip. They teach you what abstract classes and interfaces are in Java, then move on. They don't show you how to build the same guarantees in JavaScript — which is exactly what interviewers ask when they want to test depth.
In Lesson 3.5, you'll learn four concrete patterns for enforcing abstraction in vanilla JavaScript: throw in the constructor, throw in the method body, Symbol-based contracts, and Proxy-based enforcement. You'll also see the TypeScript approach and understand exactly what it costs you to go back to runtime-only enforcement.
Read the full lesson -> [link]
#OOP #JavaScript #Abstraction #InterviewPrep
What You'll Learn
- Why JavaScript has no native abstract class or interface, and what that actually means at runtime
- How to prevent direct instantiation by throwing in the constructor
- How to enforce method contracts by throwing in unimplemented method bodies
- How Symbol-based contracts simulate interface definitions
- How the
Proxyobject can enforce contracts at the point of use - When to reach for TypeScript instead of building runtime guards
The Analogy — A Blueprint That Refuses to Become a Building
Imagine you're an architect. You write a blueprint for a "generic building" that specifies: every building must have a getFloorCount() method and a calculateRentPerUnit() method. The blueprint itself is abstract — it defines the rules but does not describe any specific building.
Now imagine a construction crew takes your blueprint and tries to build the "generic building" directly, without filling in the specific details. In most typed languages, the compiler stops them before they even start. The compiler reads the blueprint, sees it's marked abstract, and refuses to generate a real building.
JavaScript doesn't have a compiler in that sense. The crew can start building immediately. Your only option is to put a guard inside the blueprint itself: a tripwire that fires the moment someone tries to move in without filling in the required details. That's what runtime abstraction enforcement in JavaScript looks like — not prevention at the blueprint stage, but a loud failure the moment someone tries to use an incomplete implementation.
Why JavaScript Has No Native Abstract Keyword
JavaScript was designed as a scripting language for web pages. The first version shipped in 10 days. Abstract classes, interfaces, and access modifiers were not priorities. The language was dynamic by design — types are checked at runtime, not compile time.
When ES6 introduced the class keyword in 2015, it was syntactic sugar over the existing prototype-based system. The TC39 committee added class, extends, super, and static. They did not add abstract or interface because those concepts require compile-time enforcement — and JavaScript doesn't have a compile step in its base form.
TypeScript fills this gap, but TypeScript compiles to JavaScript. By the time your code runs in a browser or Node.js, all TypeScript type information — including abstract and interface — has been erased. What runs is plain JavaScript. This is why understanding runtime enforcement patterns matters: TypeScript gives you safety during development, but only runtime checks protect you in production.
Pattern 1 — Throw in the Constructor
The simplest pattern. Add a guard at the top of the base class constructor that checks whether this.constructor is the base class itself. If so, someone is trying to instantiate it directly. Throw an error.
class Shape {
constructor() {
// Check if someone is trying to instantiate Shape directly
// 'this.constructor' is the class that was actually called with 'new'
// If it equals Shape itself, no subclass was involved — throw
if (new.target === Shape) {
throw new Error(
'Shape is an abstract class. Instantiate a subclass like Circle or Rectangle.'
);
}
}
// Abstract method — subclasses must override this
// The method exists here only to signal the contract
area() {
throw new Error('Shape.area() is abstract. Implement it in your subclass.');
}
// Concrete method — shared behavior all shapes get for free
describe() {
return `This shape has an area of ${this.area()}.`;
}
}
class Circle extends Shape {
constructor(radius) {
super(); // Calls Shape constructor — new.target is Circle, so no error
this.radius = radius;
}
area() {
// Concrete implementation — this satisfies the contract
return (Math.PI * this.radius * this.radius).toFixed(2);
}
}
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return (this.width * this.height).toFixed(2);
}
}
// Direct instantiation fails immediately
try {
const s = new Shape();
} catch (e) {
console.log(e.message);
// "Shape is an abstract class. Instantiate a subclass like Circle or Rectangle."
}
// Subclasses work fine
const c = new Circle(5);
console.log(c.area()); // "78.54"
console.log(c.describe()); // "This shape has an area of 78.54."
const r = new Rectangle(4, 6);
console.log(r.area()); // "24.00"
console.log(r.describe()); // "This shape has an area of 24.00."
new.target is the key here. It was introduced in ES6 specifically for this use case. Inside a constructor, new.target refers to the class that was directly invoked with new. In a subclass constructor, new.target is the subclass — not the base class. So the check new.target === Shape only fires when someone writes new Shape() directly.
[PERSONAL EXPERIENCE]: This pattern is the one I reach for first in vanilla JavaScript projects. It's one line, it's readable, and it fails loudly at the right moment — during instantiation, not somewhere buried in a method call stack later.
Pattern 2 — Throw in the Method Body
Even if you allow the base class to be instantiated (or if it's being called via super()), you still need to enforce that subclasses implement required methods. The pattern: leave the method body in the base class, but make it throw with a clear message.
class DataExporter {
constructor(name) {
// No instantiation guard here — we allow the base to be constructed
// Useful when the base class has genuine shared state
this.name = name;
this.createdAt = new Date().toISOString();
}
// Abstract method — every exporter must define its own format
// This throw acts as a reminder and a runtime contract
serialize(data) {
throw new Error(
`${this.constructor.name} must implement serialize(data). ` +
`DataExporter.serialize() is not implemented.`
);
}
// Abstract method — every exporter must define where it sends data
send(payload) {
throw new Error(
`${this.constructor.name} must implement send(payload). ` +
`DataExporter.send() is not implemented.`
);
}
// Concrete template method — calls the abstract methods in sequence
// This is the Template Method pattern combined with abstraction
export(data) {
console.log(`[${this.name}] Starting export at ${this.createdAt}`);
const payload = this.serialize(data); // Will throw if not implemented
this.send(payload); // Will throw if not implemented
console.log(`[${this.name}] Export complete.`);
}
}
class CSVExporter extends DataExporter {
serialize(data) {
// Real implementation: convert array of objects to CSV string
const headers = Object.keys(data[0]).join(',');
const rows = data.map(row => Object.values(row).join(','));
return [headers, ...rows].join('\n');
}
send(payload) {
// Simulated send — in real code this would write to a file or HTTP endpoint
console.log('Sending CSV payload:\n' + payload);
}
}
class IncompleteExporter extends DataExporter {
// Missing serialize() and send() — forgot to implement them
}
const csv = new CSVExporter('ReportTool');
csv.export([
{ name: 'Alice', score: 95 },
{ name: 'Bob', score: 87 },
]);
// [ReportTool] Starting export at 2026-04-22T...
// Sending CSV payload:
// name,score
// Alice,95
// Bob,87
// [ReportTool] Export complete.
// Calling export on the incomplete subclass will throw at the right step
const broken = new IncompleteExporter('BrokenTool');
try {
broken.export([{ name: 'Charlie' }]);
} catch (e) {
console.log(e.message);
// "IncompleteExporter must implement serialize(data). ..."
}
Notice this.constructor.name in the error message. This surfaces the actual subclass name — not "DataExporter" — which makes the error dramatically easier to debug. It tells the developer exactly which class is missing the implementation.
[UNIQUE INSIGHT]: Combining Pattern 1 (throw in constructor) and Pattern 2 (throw in method) gives you the full behavior of an abstract class: protection against direct instantiation, and enforcement that subclasses satisfy the contract. Using them together in a base class is the closest vanilla JavaScript gets to a first-class abstract class.
Pattern 3 — Symbol-Based Contracts
Symbols are unique, non-enumerable identifiers. They never collide. A Symbol-based contract uses a shared Symbol as a "contract key" — each required method gets its own Symbol, and implementors register their implementations against those Symbols. This mimics interface-style programming without TypeScript.
// Define the "interface" as a frozen object of Symbols
// Each Symbol represents one required method
const Serializable = Object.freeze({
serialize: Symbol('Serializable.serialize'),
deserialize: Symbol('Serializable.deserialize'),
});
const Sendable = Object.freeze({
send: Symbol('Sendable.send'),
});
// Validator: checks that an object implements a "contract" (set of Symbols)
function implementsContract(obj, contract, contractName) {
const missing = [];
for (const [methodName, symbol] of Object.entries(contract)) {
if (typeof obj[symbol] !== 'function') {
missing.push(methodName);
}
}
if (missing.length > 0) {
throw new Error(
`${obj.constructor.name} does not implement ${contractName}. ` +
`Missing: ${missing.join(', ')}.`
);
}
return true;
}
// A class that implements both "interfaces"
class JSONPackage {
constructor(data) {
this.data = data;
// Validate the contract immediately on construction
implementsContract(this, Serializable, 'Serializable');
implementsContract(this, Sendable, 'Sendable');
}
// Implement the Serializable contract
[Serializable.serialize]() {
return JSON.stringify(this.data);
}
[Serializable.deserialize](raw) {
return JSON.parse(raw);
}
// Implement the Sendable contract
[Sendable.send](destination) {
const payload = this[Serializable.serialize]();
console.log(`Sending to ${destination}: ${payload}`);
}
}
const pkg = new JSONPackage({ user: 'Alice', score: 99 });
pkg[Sendable.send]('https://api.example.com/report');
// Sending to https://api.example.com/report: {"user":"Alice","score":99}
// A class that forgets the Sendable contract
class BrokenPackage {
constructor(data) {
this.data = data;
// Only implements Serializable, forgets Sendable
implementsContract(this, Serializable, 'Serializable');
implementsContract(this, Sendable, 'Sendable'); // This line will throw
}
[Serializable.serialize]() {
return JSON.stringify(this.data);
}
[Serializable.deserialize](raw) {
return JSON.parse(raw);
}
// send() is missing — constructor will catch this
}
try {
const broken = new BrokenPackage({ x: 1 });
} catch (e) {
console.log(e.message);
// "BrokenPackage does not implement Sendable. Missing: send."
}
The Symbols are the keys. Because Symbols are globally unique, no string collision is possible. Because they are non-enumerable by default when used as computed property keys, they stay off Object.keys() — keeping the public API surface clean. The contract object is exported from a shared module, and any class that wants to "implement the interface" imports and uses the same Symbols.
[ORIGINAL DATA]: This pattern mirrors how the JavaScript language itself uses Symbols for built-in protocols. Symbol.iterator is JavaScript's native "Iterable interface": any object that defines [Symbol.iterator]() works with for...of, spread syntax, and destructuring. Symbol.toPrimitive defines how an object converts to a primitive. The same mechanism that powers these language features is available for user-defined contracts.
Pattern 4 — Proxy-Based Enforcement
The Proxy object intercepts operations on a target object. A Proxy-based abstract class wrapper intercepts construct traps (the new operation), checks whether required methods are present on the resulting instance, and throws before returning the object if the contract is violated. This is the most powerful pattern — and the most expensive.
// Factory that wraps a class in a Proxy to enforce required methods
function makeAbstract(BaseClass, requiredMethods) {
return new Proxy(BaseClass, {
construct(target, args, newTarget) {
// Prevent direct instantiation of the base class
if (newTarget === target) {
throw new Error(
`${target.name} is abstract and cannot be instantiated directly.`
);
}
// Create the instance normally
const instance = Reflect.construct(target, args, newTarget);
// Check that all required methods are implemented on the instance
const missing = requiredMethods.filter(
method => typeof instance[method] !== 'function'
);
if (missing.length > 0) {
throw new Error(
`${newTarget.name} does not implement required methods: ${missing.join(', ')}. ` +
`These are required by abstract class ${target.name}.`
);
}
return instance;
}
});
}
// Define the abstract base class and wrap it
const Animal = makeAbstract(
class Animal {
constructor(name) {
this.name = name;
}
// Default concrete behavior — all animals can do this
toString() {
return `[Animal: ${this.name}]`;
}
},
['speak', 'move'] // required abstract methods
);
class Dog extends Animal {
constructor(name) {
super(name);
}
speak() {
return `${this.name} says: Woof!`;
}
move() {
return `${this.name} runs on four legs.`;
}
}
class IncompleteAnimal extends Animal {
constructor(name) {
super(name);
}
speak() {
return `${this.name} makes a sound.`;
}
// Missing: move()
}
// Direct instantiation is blocked
try {
const a = new Animal('Generic');
} catch (e) {
console.log(e.message);
// "Animal is abstract and cannot be instantiated directly."
}
// Complete subclass works
const dog = new Dog('Rex');
console.log(dog.speak()); // "Rex says: Woof!"
console.log(dog.move()); // "Rex runs on four legs."
console.log(dog.toString()); // "[Animal: Rex]"
// Incomplete subclass fails at construction time
try {
const incomplete = new IncompleteAnimal('Ghost');
} catch (e) {
console.log(e.message);
// "IncompleteAnimal does not implement required methods: move. ..."
}
The key difference between this pattern and the others: enforcement happens at new time, automatically, without any code inside the base class constructor. The base class author defines the required methods as metadata outside the class body. The Proxy wraps the class and handles all enforcement invisibly.
The trade-off: Proxy has a measurable performance cost. Every construct call goes through the trap. In hot code paths — classes instantiated thousands of times per second — the overhead is real. For most application code, this is not a concern. For performance-critical inner loops, it is.
The TypeScript Approach
TypeScript adds abstract and interface as first-class keywords. The enforcement is purely at compile time — the TypeScript compiler rejects programs that violate the contracts before generating any JavaScript.
// TypeScript — this is NOT valid JavaScript
// Shown here to contrast with the runtime patterns above
abstract class Shape {
abstract area(): number; // Must be implemented by subclasses — compiler enforces this
abstract perimeter(): number;
// Concrete method — works in TypeScript abstract classes just like in Java
describe(): string {
return `Area: ${this.area()}, Perimeter: ${this.perimeter()}`;
}
}
interface Printable {
print(): void; // All implementors must provide this method
}
// TypeScript class implementing both abstract class and interface
class Circle extends Shape implements Printable {
constructor(private radius: number) {
super();
}
area(): number {
return Math.PI * this.radius * this.radius;
}
perimeter(): number {
return 2 * Math.PI * this.radius;
}
print(): void {
console.log(this.describe());
}
}
// TypeScript compiler will reject this:
// const s = new Shape();
// Error: Cannot create an instance of an abstract class.
// TypeScript compiler will also reject an incomplete subclass:
// class BadCircle extends Shape { }
// Error: Non-abstract class 'BadCircle' does not implement abstract member 'area'.
const c = new Circle(5);
c.print(); // "Area: 78.539..., Perimeter: 31.415..."
// What the compiled JavaScript actually looks like (ES2020 target):
//
// class Shape {
// describe() {
// return `Area: ${this.area()}, Perimeter: ${this.perimeter()}`;
// }
// }
//
// class Circle extends Shape {
// constructor(radius) {
// super();
// this.radius = radius;
// }
// area() { return Math.PI * this.radius * this.radius; }
// perimeter() { return 2 * Math.PI * this.radius; }
// print() { console.log(this.describe()); }
// }
//
// Notice: 'abstract' is gone. 'implements Printable' is gone.
// TypeScript's safety net disappears at runtime.
The compiled output makes the situation clear. TypeScript abstract and interface produce zero runtime code. They only exist in the .ts source. If your code is consumed by plain JavaScript (no TypeScript compilation step), those guarantees evaporate. This is why runtime patterns still matter even in TypeScript projects — particularly for library authors and cross-team APIs.
Choosing the Right Pattern
DECISION GUIDE: Which Pattern to Use?
---------------------------------------
Situation Recommended Pattern
----------- -------------------
TypeScript project, internal codebase TypeScript abstract + interface
(compile-time, zero runtime cost)
Small JS project, base class is simple Throw in constructor (new.target)
+ throw in method body
Multiple "interfaces" on one class Symbol-based contracts
Shared contracts across modules
Library or framework base class Proxy-based enforcement
where completeness check at new() (catches missing methods automatically)
is more important than raw speed
Prototyping / quick scripts Throw in method body only
(simplest, lowest overhead)
Common Mistakes
-
Checking
this.constructor.nameas a string instead of usingnew.target. Writingif (this.constructor.name === 'Shape')works but is brittle — minifiers rename classes.new.target === Shapecompares by reference, which survives minification. -
Forgetting that TypeScript abstraction is erased at runtime. A TypeScript
abstract classcompiled to JavaScript has noabstractenforcement in the output. If another JavaScript module imports your compiled class, it can instantiate it directly. Always add runtime guards to TypeScript abstract classes that are exposed as public APIs. -
Using Proxy on hot instantiation paths. A
Proxyconstruct trap adds overhead to everynewcall. If your class is instantiated thousands of times per second (tight loops, data processing pipelines), benchmark before committing to this pattern. For most application-level code, the cost is negligible. -
Not including the subclass name in error messages. Throwing
throw new Error('Not implemented')tells you nothing about which class failed. Always usethis.constructor.namein the message. It turns a 20-minute debugging session into a 20-second one. -
Implementing Symbol-based contracts without exporting the Symbols. If each module creates its own copy of
Symbol('serialize'), the Symbols will not be equal — Symbols are unique by design. The contract Symbols must come from a single shared module that all participants import.
Interview Questions
Q: JavaScript has no abstract keyword. How would you prevent a base class from being instantiated directly?
Use
new.targetinside the base class constructor.new.targetholds the class that was directly invoked withnew. Ifnew.target === BaseClass, the call is direct and you throw. When a subclass callssuper(),new.targetis the subclass, so no error fires. This is the standard runtime enforcement pattern for JavaScript.
Q: What is the difference between TypeScript's abstract and a runtime throw in the method body?
TypeScript
abstractis a compile-time constraint. The TypeScript compiler rejects code that instantiates an abstract class or misses an abstract method before any JavaScript runs. A runtime throw is enforced only when the code actually executes. TypeScript's approach catches errors earlier, but its protections disappear in the compiled output. Runtime throws protect against JavaScript consumers who bypass TypeScript entirely.
Q: What does new.target contain inside a constructor, and why is it useful for abstract class patterns?
new.targetrefers to the class that was directly invoked with thenewkeyword. In a base class constructor, ifnew.target === BaseClass, the base class was called directly. Ifnew.target === SubClass, a subclass constructor calledsuper(). This distinction is exactly what you need to allow subclasses while blocking direct base class instantiation.
Q: How can you simulate an "interface" in vanilla JavaScript without TypeScript?
One approach is a Symbol-based contract: define an object of Symbols where each Symbol represents a required method. Implementors attach their methods using those Symbols as computed property keys. A validator function iterates the contract object and checks that each Symbol maps to a function on the object. Because Symbols are globally unique, there is no name collision risk between contracts.
Q: Why is the Proxy-based abstract class pattern considered the most "automatic" enforcement, and what is its trade-off?
The Proxy wraps the class with a
constructtrap that fires on everynewcall. It checks required methods on the new instance before returning it — no code inside the base class or its subclasses is needed. The enforcement is invisible and centrally managed. The trade-off is performance: every instantiation passes through the Proxy trap, which has measurable overhead compared to a direct constructor call. For application-level code this is rarely a problem, but it is a concern for high-frequency instantiation in tight loops.
Quick Reference — Cheat Sheet
ABSTRACTION IN JAVASCRIPT — FOUR PATTERNS
===========================================
Pattern 1: Throw in Constructor (new.target)
----------------------------------------------
class Base {
constructor() {
if (new.target === Base) {
throw new Error('Base is abstract.');
}
}
}
+ Simple, readable, one line
+ Correct across minifiers (reference comparison, not name string)
- Only blocks direct instantiation, not missing method overrides
Pattern 2: Throw in Method Body
---------------------------------
class Base {
doWork() {
throw new Error(`${this.constructor.name} must implement doWork().`);
}
}
+ Easy to add incrementally
+ Error message names the offending subclass
- Fails at call time, not at instantiation time
- Must be added to every abstract method manually
Pattern 3: Symbol-Based Contracts
-----------------------------------
const MyInterface = Object.freeze({
serialize: Symbol('MyInterface.serialize'),
});
// Implementor
class Impl {
[MyInterface.serialize]() { return '...'; }
}
// Validator
function implementsContract(obj, contract, name) {
for (const [method, sym] of Object.entries(contract)) {
if (typeof obj[sym] !== 'function') throw new Error(`Missing: ${method}`);
}
}
+ Simulates multiple interface implementation
+ Symbols are globally unique — no name collisions
+ Non-enumerable by default — clean public API
- More setup than simple throws
- Callers use [Symbol] syntax for method calls
Pattern 4: Proxy-Based Enforcement
-------------------------------------
const Abstract = makeAbstract(BaseClass, ['method1', 'method2']);
+ Automatic — no code inside the class
+ Catches missing methods at new() time, not at call time
+ Base class stays clean
- Performance cost on every instantiation
- Requires Proxy (ES6+, not polyfillable)
TypeScript Approach
--------------------
abstract class Base {
abstract doWork(): void;
}
interface Printable {
print(): void;
}
class Impl extends Base implements Printable { ... }
+ Compile-time errors — caught before running
+ Clean syntax, IDE support
- Erased at runtime — zero enforcement in compiled JS
- Requires TypeScript toolchain
KEY RULES
----------
new.target === ClassName -> direct instantiation (block it)
new.target === SubClass -> subclass calling super() (allow it)
Always use this.constructor.name in error messages — not hardcoded strings
Symbol contracts must come from a single shared module
TypeScript abstract + runtime throw = defense in depth for public APIs
PATTERN SELECTION GUIDE
------------------------
TypeScript codebase, internal use -> TS abstract + interface
Small JS project -> throw in constructor + method
Multi-interface simulation -> Symbol contracts
Auto-enforced, library/framework API -> Proxy
Previous: Lesson 3.4 — Abstract Class vs Interface -> Next: Lesson 4.1 — Inheritance Basics ->
This is Lesson 3.5 of the OOP Interview Prep Course — 8 chapters, 41 lessons.