Lesson 4 of 5

Losing Interfaces & Generics

interface and generics weren't just extra syntax — they were the two biggest structural guarantees TypeScript gave you above the level of a single variable. An interface guaranteed an object had a specific shape everywhere it was used. A generic guaranteed a container stayed connected to the type it held, instead of forgetting it the moment the value went in. Plain JavaScript has no direct replacement for either — what it has are informal conventions that approximate the same intent, enforced by nothing but the reader's attention.

💡 The Bottom Line

Without interface, plain JS falls back on duck typing — "if it has the properties I need, I'll use it," with no upfront guarantee those properties exist — plus naming conventions and, if you did lesson 2's JSDoc work, a @typedef comment documenting the intended shape. Without generics, a function or class that's supposed to work "for any type, but stay consistent about which one" just... doesn't enforce that consistency; it works with whatever you pass it, correctly or not. Both are real losses, not inconveniences — nothing below fully replaces the compile-time guarantee, it only narrows how often you'll get bitten by its absence.

What an interface guaranteed

TypeScript
interface User {
  id: number;
  name: string;
  roles: string[];
}

function isAdmin(user: User): boolean {
  return user.roles.includes("admin");
}

isAdmin({ id: 1, name: "Sam" });
// compile error: Property 'roles'
// is missing in type
// '{ id: number; name: string; }'
// but required in type 'User'.
Plain JavaScript
function isAdmin(user) {
  return user.roles.includes("admin");
}

isAdmin({ id: 1, name: "Sam" });
// no error until this line runs:
// TypeError: Cannot read properties
// of undefined (reading 'includes')

📋 Key differences

  • TypeScript checked every call site against the interface before anything ran — the missing roles field was caught the moment you wrote the call, not the moment it executed.
  • Plain JS has no concept of "this object is supposed to have these fields" beyond however you named the parameter and whatever comment you wrote nearby — the object you pass in either happens to have roles or it doesn't, and JS won't tell you which until it tries to use it.
  • Duck typing — "if it walks like a duck and quacks like a duck" — is JavaScript's actual answer: code doesn't check an object's declared type, it just tries to use the properties it needs and fails at that exact point if they're missing.

Approximating a shape guarantee

Since there's no compiler to hold code to an interface, the closest plain-JS equivalents are documentation plus a runtime check at the boundary — the same guard-clause idea from the previous lesson, applied to a whole shape instead of one parameter.

/**
 * @typedef {Object} User
 * @property {number} id
 * @property {string} name
 * @property {string[]} roles
 */

/**
 * @param {User} user
 * @returns {boolean}
 */
function isAdmin(user) {
  if (!Array.isArray(user.roles)) {
    throw new TypeError("isAdmin expects a user with a roles array");
  }
  return user.roles.includes("admin");
}

The @typedef restores editor autocomplete and (with @ts-check) some compile-style checking on your own code — but it does nothing for a value that arrives at runtime from outside your control, like a parsed API response. The manual Array.isArray guard is what actually protects that case, and it only exists because someone wrote it.

What a generic guaranteed

TypeScript
class Box<T> {
  constructor(private value: T) {}
  get(): T {
    return this.value;
  }
}

const numberBox = new Box<number>(42);
const n: number = numberBox.get();
// n is guaranteed to be a number —
// the compiler tracked T all the way through

const bad: string = numberBox.get();
// compile error: Type 'number' is not
// assignable to type 'string'.
Plain JavaScript
class Box {
  constructor(value) {
    this.value = value;
  }
  get() {
    return this.value;
  }
}

const numberBox = new Box(42);
const n = numberBox.get();
// n is just "whatever get() returns" —
// nothing tracked that it started as a number

const alsoFine = new Box("oops");
// nothing stops mixing types across
// different Box instances either

⚠️ Gotcha

The specific thing a generic bought you — "this container's output type is the same type as whatever went in, tracked automatically" — has no plain-JS substitute at all, not even an approximate one. A JSDoc @template tag exists and can express something similar for editor hints, but it's rarely used in practice and doesn't carry the same weight as real generic inference across a whole call chain. In plain JS, a "generic-shaped" class or function like Box above simply accepts and returns whatever it's given — the "stays consistent" part was entirely the compiler's doing, and once it's gone, consistency is just a convention you have to maintain by discipline.

Naming conventions as a weak stand-in

Absent any tooling, plain-JS codebases often lean on naming and structure to communicate intent that used to be a type: a function named parseUserResponse implies (but doesn't enforce) what shape it expects and returns; a factory function returning a plain object literal instead of a class instance is often treated, by convention, as if it "is" a certain type even though nothing checks that. These conventions help a human reader; they do nothing at runtime, and nothing stops the next person (or your own code six months later) from breaking them silently.

🔧 Try It Yourself

Take the Box class above, create two instances — one holding a number, one holding a string — and write a function that adds 1 to whatever .get() returns. Call it with both boxes and observe what happens with the string one (string concatenation instead of addition, or NaN, depending on how you wrote it) — there's no error anywhere, just a wrong answer.

🙋 There Are No Dumb Questions

Q: Is duck typing just a worse version of interfaces, or is it actually a different way of thinking about types?
A: A bit of both. Duck typing is genuinely how dynamically-typed languages have always worked, and plenty of flexible, working software is built entirely on it — it's not automatically wrong. But coming from TypeScript, it's worth being honest that it's strictly less safe for the specific case of "I need to guarantee this object has these fields before I use them" — duck typing defers that check to the exact moment of use (or never, if the code path is rarely exercised), where an interface caught it at every call site, immediately, every time.

✅ Quick Check

In plain JavaScript, a Box class holds any value passed to its constructor and returns it from .get(). What enforces that a single Box instance's output type matches what it was constructed with?


Next up: time for the recap — but first, a rundown of the specific everyday traps that catch even developers who've internalized everything so far: silent coercion, null vs undefined, and switch statements that used to be exhaustively checked.

← Back to
Next →