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.
const value = "hello"
console.log(typeof value) // "string"
const num = 42
console.log(typeof num) // "number"
const fn = () => {}
console.log(typeof fn) // "function"
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 |
// 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.
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"
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.
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
}
}
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.
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"
}
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.
// 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."
// 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"]
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.
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 []
}
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."
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)
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.
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.
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.
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
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.
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.
// 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
}
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
// 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 |
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.