Lesson 3 of 5

Runtime Validation Instead of Compile-Time Checks

JSDoc gets some of the compiler's power back, but it's opt-in — someone has to write the comments, turn on @ts-check, and keep both accurate. When you can't rely on any of that (a file nobody's annotated yet, a function that takes data from outside your program entirely), the fallback is the same one JavaScript developers have always used: check the value yourself, by hand, at the moment you receive it, and refuse to proceed if it's wrong.

💡 The Bottom Line

Where TypeScript's compiler used to guarantee a function's inputs matched its signature before your code ever ran, plain JavaScript only gives you that guarantee if you write it yourself — with typeof/instanceof checks, sane default parameter values, and an early throw the moment something doesn't look right. It's more typing than an annotation, it runs on every call instead of once at compile time, and it only covers exactly what you remembered to check — but it's the only net left once the compiler is gone.

Guard clauses at the top of a function

The most common pattern: check each argument's type explicitly, and throw immediately with a clear message if it's wrong, before any real logic runs. This turns a confusing failure three lines later into an obvious one at the exact call site.

function applyDiscount(price, percent) {
  if (typeof price !== "number") {
    throw new TypeError(`price must be a number, got ${typeof price}`);
  }
  if (typeof percent !== "number") {
    throw new TypeError(`percent must be a number, got ${typeof percent}`);
  }
  return price - (price * percent) / 100;
}

applyDiscount(100, "ten");
// throws immediately, at the call site:
// TypeError: percent must be a number, got string

This doesn't stop the string from being passed in — nothing can, at runtime, since JavaScript has no compile step to reject it beforehand. What it does is convert a silent, delayed NaN into a loud, immediate error that points at the actual problem, which is most of what the TypeScript compiler was buying you in the first place: fast, specific feedback instead of a mystery bug several calls downstream.

instanceof for class-shaped values

For values that should be instances of a particular class rather than a primitive, the equivalent check is instanceof.

class NetworkError {
  constructor(status) {
    this.status = status;
  }
}

function logError(err) {
  if (!(err instanceof NetworkError)) {
    throw new TypeError("logError expects a NetworkError instance");
  }
  console.log("HTTP", err.status);
}

Default parameter values as a cheap safety net

Default parameters don't validate anything, but they close off one common source of the exact bug TypeScript used to catch — calling a function with too few arguments and getting undefined silently threaded through the rest of the function.

function greet(name = "friend") {
  return `Hi ${name}`;
}

greet();
// TypeScript: compile error, missing required argument
// Plain JS without a default: "Hi undefined"
// Plain JS with the default above: "Hi friend"

⚠️ Gotcha

A default parameter only kicks in when an argument is undefined or omitted entirely — it does not kick in for null. greet(null) still returns "Hi null", not "Hi friend", which is a common surprise for anyone assuming a default parameter behaves like "any falsy/missing value gets replaced."

Where to draw the line

Writing a guard clause for every parameter of every function quickly becomes its own kind of noise. In practice, the discipline that pays off is checking at boundaries — where data enters your program from somewhere you don't control: function arguments on a public/exported function, data parsed from an API response, values read from localStorage or a form input. Internal functions called only by your own already-validated code can usually trust their inputs, the same way TypeScript would have let inference carry a checked value through several internal calls without re-annotating it each time.

For anything beyond a handful of ad-hoc checks — validating a whole API response shape, for example — most JavaScript codebases reach for a small validation library (Zod, Yup, and PropTypes for React component props are common choices) rather than hand-writing dozens of typeof checks. Those libraries are worth knowing exist, but the underlying idea is exactly the guard-clause pattern above: check the shape at the boundary, throw or report a clear error if it doesn't match, and only proceed once it does.

🔧 Try It Yourself

Take the applyDiscount function from the guard-clause example above and add a third check: percent must be between 0 and 100 inclusive, throwing a RangeError otherwise. Call it with 150 and confirm you get an immediate, specific error instead of a silently wrong discount.

🙋 There Are No Dumb Questions

Q: Doesn't writing all these manual checks basically mean I'm hand-rolling a worse version of what TypeScript already did automatically?
A: Yes, honestly — that's the core trade-off of losing the compiler. Manual guard clauses only run when the function is actually called (so a bug in a rarely-exercised code path can still hide for a long time), only check what you remembered to write, and add a small amount of runtime overhead on every call that TypeScript's checks never had, since those were erased before the code ever ran. The upside is they work with zero tooling and catch the highest-risk spots — boundaries — which is usually enough to prevent the worst failures, even if it's not the same guarantee.

✅ Quick Check

Given function greet(name = "friend") { return `Hi ${name}`; }, what does greet(null) return?


Next up: guard clauses protect individual function calls, but TypeScript's interface and generics did more than that — they guaranteed whole object shapes and let containers stay connected to what they held. Losing those is a bigger structural change than losing a single parameter check.

← Back to
Next →