TypeScripttypeof Guards

typeof Guards

TypeScript's type system is built around the idea of narrowing — progressively restricting a broad type to a more specific one based on runtime checks. The typeof operator is one of the most fundamental tools for narrowing primitive types.

When TypeScript sees a typeof check inside an if block, it doesn't just treat it as a plain boolean expression. It understands what that check means — and automatically narrows the type of your variable inside that branch. This is called a type guard, and typeof is the simplest built-in one TypeScript recognises.

The typeof Operator

The JavaScript typeof operator returns a lowercase string that describes the runtime type of a value. TypeScript knows all the possible strings it can return, and uses them as literal string types for narrowing.

TS
const value = "hello"
console.log(typeof value) // "string"

const num = 42
console.log(typeof num) // "number"

const fn = () => {}
console.log(typeof fn) // "function"
Note
The return type of typeof in TypeScript is "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function". These 8 strings cover every value JavaScript can produce.
All 8 typeof Strings

Here is a complete reference for what typeof returns for each type of value:

Value

typeof Result

Notes

"hello"

"string"

Any string literal or String value

42, NaN, Infinity

"number"

All numbers including NaN and Infinity

9007199254740991n

"bigint"

BigInt literals (ES2020+)

true, false

"boolean"

Boolean literals

Symbol("id")

"symbol"

Unique symbol values

undefined

"undefined"

Uninitialized variables, void functions

null, {}, []

"object"

Objects, arrays, AND null — gotcha!

() => {}, function(){}

"function"

Callable values

TS
// Demonstrate each typeof string
const examples = [
  "hello",
  42,
  9007199254740991n,
  true,
  Symbol("id"),
  undefined,
  null,
  {},
  [],
  () => {},
]

for (const val of examples) {
  console.log(typeof val)
}
// "string"
// "number"
// "bigint"
// "boolean"
// "symbol"
// "undefined"
// "object"   <- null, object, AND array all return "object"
// "object"
// "object"
// "function"
How TypeScript Narrows with typeof

When you write typeof x === "string" inside an if condition, TypeScript analyses that check and adjusts the inferred type of x inside each branch automatically.

TS
function format(value: string | number): string {
  if (typeof value === "string") {
    // Inside this block, TypeScript knows value: string
    return value.toUpperCase()
  }

  // Outside the if, TypeScript knows value: number
  return value.toFixed(2)
}

console.log(format("hello"))  // "HELLO"
console.log(format(3.14159)) // "3.14"
Tip
TypeScript narrows in both the if branch AND the remainder of the function after the branch. This is called the else path or narrowed complement. After ruling out "string", TypeScript knows the only remaining possibility is number.
The null Gotcha

One of the oldest quirks in JavaScript is that typeof null === "object". This is a historical bug from JavaScript's very first implementation and will never be fixed for backward-compatibility reasons.

This means you cannot safely narrow away null using typeof alone.

TS
function processData(data: object | null) {
  if (typeof data === "object") {
    // TypeScript: data is still object | null here!
    // typeof null === "object" passes the check
    data.toString() // Error: data is possibly null
  }
}

// The correct fix: add a null check
function processDataSafe(data: object | null) {
  if (typeof data === "object" && data !== null) {
    // Now TypeScript knows: data is object (not null)
    data.toString() // OK
  }
}
Warning
Never rely on typeof x === "object" alone to rule out null. Always pair it with x !== null when the value could be null.
typeof with Union Types

typeof guards shine when working with union types that mix primitives. TypeScript tracks each narrowing step and computes the remaining possibilities at every point.

TS
type Input = string | number | boolean | null | undefined

function describe(input: Input): string {
  if (typeof input === "string") {
    // input: string
    return `String of length ${input.length}`
  }

  if (typeof input === "number") {
    // input: number
    return `Number: ${input.toFixed(2)}`
  }

  if (typeof input === "boolean") {
    // input: boolean
    return `Boolean: ${input ? "yes" : "no"}`
  }

  if (input === null) {
    // input: null  (typeof null === "object", so use strict equality)
    return "null value"
  }

  // input: undefined — the only remaining type
  return "no value provided"
}
Note
TypeScript uses control flow analysis to track what types have been eliminated at each point. After the last typeof check, it knows input can only be null | undefined.
Custom Type Guard Functions

Sometimes you want to encapsulate a type check into a reusable function. A plain boolean-returning function loses the narrowing information — TypeScript won't narrow based on it.

The solution is a user-defined type guard: a function whose return type uses the special x is Type syntax.

TS
// Plain boolean — TypeScript won't narrow for callers
function isStringPlain(value: unknown): boolean {
  return typeof value === "string"
}

// Type guard — TypeScript narrows based on the return value
function isString(value: unknown): value is string {
  return typeof value === "string"
}

function greet(value: unknown) {
  if (isString(value)) {
    // TypeScript knows: value is string
    console.log(value.toUpperCase()) // OK
  }
}

The value is string return annotation is called a type predicate. It tells TypeScript: "if this function returns true, treat value as string in the calling scope."

TS
// More useful type guards
function isNumber(value: unknown): value is number {
  return typeof value === "number" && !Number.isNaN(value)
}

function isNonNullObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null
}

function isDefined<T>(value: T | null | undefined): value is T {
  return value !== null && value !== undefined
}

// Usage — the isDefined guard works great with .filter()
const values: (string | null | undefined)[] = ["hello", null, "world", undefined]
const strings = values.filter(isDefined) // string[]  <- narrowed!
console.log(strings) // ["hello", "world"]
Tip
The generic isDefined guard is extremely useful with .filter(). Without it, values.filter(v => v !== null) still returns (string | null | undefined)[] — TypeScript cannot narrow array filter results automatically.
Array.isArray() as a Type Guard

Since arrays have typeof === "object", you cannot distinguish them from plain objects with typeof. Array.isArray() is a built-in function that TypeScript recognises as a type guard.

TS
function processItems(data: string[] | string) {
  if (Array.isArray(data)) {
    // data: string[]
    return data.map(item => item.toUpperCase())
  }

  // data: string
  return [data.toUpperCase()]
}

// Works with unknown too
function flatten(input: unknown): string[] {
  if (Array.isArray(input)) {
    // input: any[]  (TypeScript cannot know element type from Array.isArray alone)
    return input.map(String)
  }

  if (typeof input === "string") {
    return [input]
  }

  return []
}
Note
When using Array.isArray() on unknown, TypeScript narrows to any[] — not a specific element type. To get a typed array, combine it with a type predicate or further element checks.
Assertion Functions

Sometimes you don't want to branch — you want to assert that a value has a certain type and throw an error if it doesn't. TypeScript 3.7 introduced assertion functions for this.

The return type asserts x is Type tells TypeScript: "after this function returns normally, x has type Type."

TS
function assertString(value: unknown): asserts value is string {
  if (typeof value !== "string") {
    throw new TypeError(`Expected string, got ${typeof value}`)
  }
}

function assertDefined<T>(value: T | null | undefined, name = "value"): asserts value is T {
  if (value === null || value === undefined) {
    throw new Error(`${name} must be defined`)
  }
}

// Usage — no if block needed, call the assertion and TypeScript narrows
function processUserInput(raw: unknown) {
  assertString(raw)
  // raw is now: string
  return raw.trim().toLowerCase()
}

const config = {
  apiKey: process.env.API_KEY, // string | undefined
}

assertDefined(config.apiKey, "API_KEY")
// config.apiKey is now: string
console.log(config.apiKey.length)
Warning
Assertion functions throw at runtime on bad input. Use them for invariants you expect to always hold (like environment variables that must be set), not for regular user input validation where you want to handle the error gracefully.
Combining typeof with Other Guards

Real-world narrowing often combines typeof with other checks: instanceof, in, literal equality, and custom type guards. TypeScript intersects all of these to compute the narrowest possible type.

TS
type ApiResponse =
  | { status: "success"; data: string[] }
  | { status: "error"; message: string }
  | null

function handleResponse(response: ApiResponse) {
  // Combine null check with property narrowing
  if (response === null) {
    console.log("No response")
    return
  }

  // Now response: { status: "success"; data: string[] } | { status: "error"; message: string }
  if (response.status === "success") {
    // response: { status: "success"; data: string[] }
    console.log(`Got ${response.data.length} items`)
  } else {
    // response: { status: "error"; message: string }
    console.error(`Error: ${response.message}`)
  }
}

// Combining typeof + instanceof
function formatDate(value: string | Date | number): string {
  if (typeof value === "string") {
    return new Date(value).toLocaleDateString()
  }

  if (typeof value === "number") {
    return new Date(value).toLocaleDateString()
  }

  // value: Date — typeof eliminated the other two branches
  return value.toLocaleDateString()
}
Practical Example: Logging Utility

A logging utility that serializes any value type is a great showcase for exhaustive typeof branching.

TS
function serialize(value: unknown): string {
  if (value === null) return "null"
  if (value === undefined) return "undefined"
  if (typeof value === "string") return value
  if (typeof value === "number") return value.toString()
  if (typeof value === "boolean") return value.toString()
  if (typeof value === "bigint") return `${value}n`
  if (typeof value === "symbol") return value.toString()
  if (typeof value === "function") return `[Function: ${value.name || "anonymous"}]`
  // typeof === "object" at this point — null already handled above
  if (Array.isArray(value)) return `[${value.map(serialize).join(", ")}]`
  try { return JSON.stringify(value) } catch { return "[Unserializable]" }
}

type LogLevel = "info" | "warn" | "error"
function log(level: LogLevel, ...args: unknown[]) {
  console[level](args.map(serialize).join(" "))
}

log("info", "Port", 3000)          // "Port 3000"
log("warn", null, [1, 2])          // "null [1, 2]"
Practical Example: Safe JSON Parser

Parsing JSON always returns unknown when typed strictly. Here is how to validate and narrow a parsed result field by field.

TS
function safeParseJSON(input: string): unknown {
  try {
    return JSON.parse(input) as unknown
  } catch {
    return undefined
  }
}

function isStringRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value)
}

interface UserConfig {
  name: string
  age: number
  active: boolean
}

function parseUserConfig(json: string): UserConfig | null {
  const parsed = safeParseJSON(json)

  if (!isStringRecord(parsed)) return null

  const { name, age, active } = parsed

  if (typeof name !== "string") return null
  if (typeof age !== "number") return null
  if (typeof active !== "boolean") return null

  // All fields narrowed — TypeScript knows this satisfies UserConfig
  return { name, age, active }
}

const good = parseUserConfig('{"name":"Alice","age":30,"active":true}')
console.log(good) // { name: "Alice", age: 30, active: true }

const bad = parseUserConfig('{"name":42,"age":"old"}')
console.log(bad) // null
Tip
This pattern — parse to unknown, then validate field by field with typeof — is the safest approach to runtime JSON validation without a schema library. For production code with complex shapes, consider Zod or io-ts, which build on the same principle.
Practical Example: Form Input Handler

Form inputs are a perfect use case for typeof guards since HTML always gives you strings, but your domain logic often needs numbers, booleans, or other types.

TS
type FieldValue = string | number | boolean | Date | null

function coerceField(raw: unknown, expectedType: string): FieldValue {
  if (typeof raw !== "string") return null

  switch (expectedType) {
    case "number": {
      const n = Number(raw)
      return Number.isNaN(n) ? null : n
    }
    case "boolean":
      return raw === "true" || raw === "1" || raw === "on"
    case "date": {
      const d = new Date(raw)
      return isNaN(d.getTime()) ? null : d
    }
    default:
      return raw.trim() || null
  }
}

// Usage
const age = coerceField("28", "number")      // 28
const active = coerceField("on", "boolean")  // true
const name = coerceField("  alice  ", "string") // "alice"
Branded Types — Advanced Pattern

A branded type (also called a nominal type or opaque type) is a primitive type tagged with a unique marker. This lets you create types like UserId that are structurally string, but TypeScript will not let you accidentally pass a plain string where a UserId is required.

You combine branded types with custom type guards to create safe, validated constructors.

TS
// Declare the brand using a unique symbol key
declare const _brand: unique symbol

type Brand<Base, Tag extends string> = Base & { readonly [_brand]: Tag }

// Create branded types
type UserId = Brand<string, "UserId">
type Email = Brand<string, "Email">
type PositiveInt = Brand<number, "PositiveInt">

// Type guard doubles as a validated constructor
function toUserId(value: unknown): UserId | null {
  if (typeof value !== "string") return null
  if (value.trim().length === 0) return null
  return value as UserId
}

// Functions that require branded types
function fetchUser(id: UserId): Promise<void> {
  return fetch(`/api/users/${id}`).then(() => {})
}

const rawId = "user_123"
// fetchUser(rawId) <- Error: Argument of type 'string' is not assignable to parameter of type 'UserId'

const userId = toUserId(rawId)
if (userId !== null) {
  fetchUser(userId) // OK — validated and branded
}
Note
Brands are erased at runtime — a branded type is still a plain primitive. The brand only exists in the type system. This pattern enforces that values pass through your validation layer without any runtime overhead.
Common Mistakes and How to Avoid Them
  • Using typeof to check for null — typeof null === "object", so always pair with !== null

  • Forgetting that typeof [] === "object" — use Array.isArray() for arrays

  • Using a plain boolean-returning function as a type guard — add the "value is Type" predicate

  • Checking typeof on class instances — use instanceof instead for objects created with new

  • Assuming typeof NaN === "number" is safe — NaN is technically a number, always check with Number.isNaN()

  • Mutating a variable after narrowing — TypeScript's narrowing resets if you reassign the variable

TS
// typeof on a class instance does NOT narrow to the class
class Dog { bark() { console.log("woof") } }
const pet: Dog | string = new Dog()
if (typeof pet === "object") { /* still not narrowed to Dog */ }
if (pet instanceof Dog) { pet.bark() } // use instanceof instead

// typeof NaN === "number" — NaN passes typeof checks
const result = 0 / 0        // NaN
if (typeof result === "number") { /* true! but result is NaN */ }
if (Number.isFinite(result)) { /* false — correct check */ }
Quick Reference

Pattern

Use When

Example

typeof x === "string"

Narrowing primitives in unions

string | number | boolean

typeof x === "object" && x !== null

Narrowing to non-null objects

Parsing JSON, API responses

Array.isArray(x)

Distinguishing arrays from objects

string[] | string

x is Type predicate

Reusable narrowing in a function

isEmail(), isUserId()

asserts x is Type

Throwing on invalid types

assertDefined(), assertString()

Branded types + guard

Enforcing validated primitives

UserId, Email, PositiveInt

Success
You now have a complete toolkit for narrowing primitive types in TypeScript. Start with plain typeof checks for inline narrowing, extract reusable checks into type guard functions with value is Type, use asserts for invariants, and reach for branded types when you need to enforce validation at the type level across your entire codebase.