Guide · Concepts · 20 October 2026 · 6 min read

Runtime type checks vs static type annotations in JavaScript

typeof and instanceof check values while code runs; TypeScript checks JSDoc-typed code before it runs. What each catches, and how they combine.

  • Examples checked with TypeScript 5.9

“Type checking JavaScript” means two different things. One is a check your code makes while it runs: typeof value === "string". The other is a check a tool makes before the code runs: TypeScript reading your JSDoc comments and reporting that a function is called with the wrong argument. They answer different questions, and most real programs need both.

Two different questions

A runtime check asks “what is this value, right now?” It runs every time the line runs, it sees the actual data, and it costs a little time on each call. It can only tell you about the values that actually arrive.

A static type check asks “can this code ever receive the wrong kind of value?” TypeScript answers it in your editor and in CI, before the code runs, by following every code path. It costs nothing at runtime, but it never sees the real data: a JSON file or an HTTP request can still contain anything.

JavaScript’s own runtime types

JavaScript values carry a type at runtime, and typeof reports it as one of eight strings: "undefined", "boolean", "number", "bigint", "string", "symbol", "function" and "object". A few results surprise people:

Valuetypeof saysWhat to use instead
null"object"value === null
[1, 2, 3]"object"Array.isArray(value)
NaN"number"Number.isNaN(value)
new Date()"object"value instanceof Date
class User {}"function"value instanceof User for its instances

instanceof checks an object’s prototype chain, so it answers “was this made by that class?” It can give false for an object created in another realm, such as an array from an iframe, which is why Array.isArray exists.

Static annotations

A JSDoc annotation is a comment. Node.js and browsers skip it, so an annotated file runs exactly as it did before. TypeScript reads the comments, combines them with what it can infer, and reports code that cannot be right: a property that does not exist, a string passed where a number is expected, a value that might be null used as if it never is. Nothing is checked while the program runs.

Where they meet: narrowing

TypeScript reads your runtime checks. Inside if (typeof value === "string"), the checker knows value is a string, so the same line that protects the program at runtime also satisfies TypeScript’s checker:

describe.js TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @param {unknown} value
 * @returns {string}
 */
export function describe(value) {
  if (typeof value === "string") return `a string of ${value.length} characters`;
  if (typeof value === "number") return `the number ${value.toFixed(2)}`;
  if (Array.isArray(value)) return `an array of ${value.length} items`;
  if (value instanceof Date) return `the date ${value.toISOString()}`;
  if (value === null) return "null";
  return typeof value;
}
Runtime checks that also narrow the static type.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

The parameter is typed unknown, which means “anything, but prove what it is before you use it”. Each branch narrows it: typeof for primitives, Array.isArray for arrays, instanceof for classes, === null for null.

Data from outside the program

JSON.parse returns any, because TypeScript cannot know what a string will contain. Annotating the result as unknown makes the checker insist on a runtime check before any use:

parse.js Error confirmed 5.9.3 TypeScript 5.9.3, run 21 October 2026: parse.js(5,13): error TS18046: 'data' is of type 'unknown'. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. parse.js(5,13): error TS18046: 'data' is of type 'unknown'. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. parse.js(5,13): error TS18046: 'data' is of type 'unknown'. checked 21 October 2026
/** @type {unknown} */
const data = JSON.parse('{"name":"Ada"}');

console.log(data.name);error TS18046: 'data' is of type 'unknown'.
Reading a property of unknown data.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

A type guard is a function that does that runtime check once and tells TypeScript the result. Its return type, value is User, says “if this returns true, value is a User”:

user.js user.ts TypeScript 5.9.3 TypeScript 5.9.3, run 21 October 2026: No errors. 6.0.3 TypeScript 6.0.3, run 21 October 2026: the same as 5.9.3. No errors. 7.0.2 TypeScript 7.0.2, run 21 October 2026: the same as 5.9.3. No errors. checked 21 October 2026
/**
 * @typedef {object} User
 * @property {string} name
 * @property {number} age
 */

/**
 * @param {unknown} value
 * @returns {value is User}
 */
export function isUser(value) {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    typeof value.name === "string" &&
    "age" in value &&
    typeof value.age === "number"
  );
}

/** @type {unknown} */
const data = JSON.parse('{"name":"Ada","age":36}');

if (isUser(data)) {
  console.log(data.name.toUpperCase(), data.age + 1);
}
interface User {
  name: string;
  age: number;
}

export function isUser(value: unknown): value is User {
  return (
    typeof value === "object" &&
    value !== null &&
    "name" in value &&
    typeof value.name === "string" &&
    "age" in value &&
    typeof value.age === "number"
  );
}

const data: unknown = JSON.parse('{"name":"Ada","age":36}');

if (isUser(data)) {
  console.log(data.name.toUpperCase(), data.age + 1);
}
A type guard that checks parsed JSON.
jsconfig.json for this example
{
  "compilerOptions": {
    "checkJs": true,
    "noEmit": true,
    "strict": true,
    "target": "es2022",
    "module": "nodenext",
    "types": []
  },
  "include": ["*.js"]
}

After if (isUser(data)), data is a User inside the branch, and the check runs on the real data every time.

What each one catches

Runtime checksStatic type checks
Whenwhile the code runsbefore it runs, in the editor and in CI
What it seesthe actual valuesevery code path, but not the data
Costa check each time the line runsnothing at runtime
Good atbad input from users, files, the network and environment variablestypos, wrong arguments, missing null checks, broken refactors

Using both

Use static types everywhere you control the code, and runtime checks at the edges where data comes in: request bodies, files, environment variables, anything parsed from a string. Write the edge checks as type guards and the two systems reinforce each other: the guard protects the running program, and the type it proves lets TypeScript check everything after it.

The examples on this page were checked with TypeScript 5.9. To set up the static half in your own project, start with the quick start.

Questions

Does a JSDoc type check anything while the program runs?

No. A JSDoc annotation is a comment, so Node.js and browsers skip it. TypeScript reads it before the code runs, in the editor and in CI; at runtime only checks you write, such as typeof and instanceof, run.

Should data from JSON.parse be typed with a cast?

A cast tells the checker to trust you and checks nothing at runtime. Type the result as unknown and narrow it with a type guard, so the shape is checked on the real data before any use.

Sources

Last reviewed 20 October 2026.