🧪Type check helpers
Small functions that answer “is this value a…?” at runtime. Each one is a TypeScript type guard: it returns value is Type, so after an if (isString(x)) TypeScript knows x is a string.
All of these are also in my kit package (external site), with tests, so you can import them from @mrmartineau/kit/utils instead of copying them.
import { isDefined, isPlainObject, isString } from '@mrmartineau/kit/utils'
const data: unknown = JSON.parse(localStorage.getItem('settings') ?? 'null')
if (isPlainObject(data) && isString(data.theme)) {
document.documentElement.dataset.theme = data.theme // data.theme: string
}
const ids = [1, null, 2, undefined].filter(isDefined) // number[]
Use them on data you don’t control: API responses, JSON.parse, localStorage, postMessage, catch errors. For whole objects from an API, a schema library (Zod (external site), Valibot (external site)) is usually better than a pile of these.
Why not just typeof?
typeof is fine for most primitives, but it has some well-known surprises:
| Value | typeof says |
|---|---|
null | 'object' |
[] | 'object' |
new Date() | 'object' |
NaN | 'number' |
class Foo {} | 'function' |
| an undeclared variable | 'undefined' (no error) |
The helpers below deal with each of these.
Primitives
export const isString = (value: unknown): value is string => typeof value === 'string'
export const isBoolean = (value: unknown): value is boolean => typeof value === 'boolean'
export const isBigInt = (value: unknown): value is bigint => typeof value === 'bigint'
export const isSymbol = (value: unknown): value is symbol => typeof value === 'symbol'
export const isFunction = (value: unknown): value is (...args: unknown[]) => unknown =>
typeof value === 'function'
type Primitive = string | number | bigint | boolean | symbol | null | undefined
export const isPrimitive = (value: unknown): value is Primitive =>
value === null || (typeof value !== 'object' && typeof value !== 'function')
isString and friends don’t match wrapper objects like new String('a'). Nobody should be making those.
Numbers
typeof NaN === 'number', so decide whether NaN and Infinity count. Usually they don’t:
// any number, including NaN and Infinity
export const isNumber = (value: unknown): value is number => typeof value === 'number'
// a real, usable number: not NaN, not Infinity
export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export const isInteger = (value: unknown): value is number => Number.isInteger(value)
Use Number.isFinite, Number.isInteger and Number.isNaN, not the global isFinite and isNaN. The global ones convert the value first, so isNaN('hello') is true and isFinite('42') is true.
To check a string that should contain a number (a form field, a URL parameter), convert it first:
export const isNumericString = (value: unknown): value is string =>
typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))
isNumericString('42') // true
isNumericString('4.2e3') // true
isNumericString('') // false: Number('') is 0
isNumericString('12px') // false
null and undefined
export const isNull = (value: unknown): value is null => value === null
export const isUndefined = (value: unknown): value is undefined => value === undefined
// null or undefined ("nil")
export const isNil = (value: unknown): value is null | undefined => value == null
// the opposite, and it keeps the rest of the type
export const isDefined = <T>(value: T): value is NonNullable<T> => value != null
value == null is the one place loose equality is the right choice: it’s true for null and undefined and nothing else.
isDefined is the one you’ll use most, often with filter:
const ids = [1, null, 2, undefined, 3].filter(isDefined) // number[]
Objects
“Object” means different things, so there are three helpers.
// anything typeof calls 'object', minus null: arrays, dates, maps, class instances…
export const isObjectLike = (value: unknown): value is object =>
typeof value === 'object' && value !== null
// an object with string keys, not an array
export const isObject = (value: unknown): value is Record<string, unknown> =>
isObjectLike(value) && !Array.isArray(value)
// only {} literals and Object.create(null): no arrays, dates, maps or class instances
export const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (!isObjectLike(value)) return false
const proto = Object.getPrototypeOf(value)
return proto === Object.prototype || proto === null
}
| Value | isObjectLike | isObject | isPlainObject |
|---|---|---|---|
{ a: 1 } | ✅ | ✅ | ✅ |
Object.create(null) | ✅ | ✅ | ✅ |
[] | ✅ | ❌ | ❌ |
new Date() | ✅ | ✅ | ❌ |
new Map() | ✅ | ✅ | ❌ |
new (class Foo {})() | ✅ | ✅ | ❌ |
null | ❌ | ❌ | ❌ |
Use isPlainObject when you’re about to go through the keys: merging settings, flattening nested objects (see Looping and iterating), deep-cloning JSON. A Date or Map has no useful keys and would be copied wrong.
isPlainObject returns false for a {} made in an iframe, because that iframe has its own Object.prototype. That rarely matters.
Does it have this key?
export const hasKey = <K extends PropertyKey>(value: unknown, key: K): value is Record<K, unknown> =>
isObjectLike(value) && Object.hasOwn(value, key)
if (hasKey(data, 'id') && isString(data.id)) {
data.id.toUpperCase()
}
Object.hasOwn only looks at the object’s own keys. key in value also finds inherited ones, like toString.
Arrays
export const isArray = (value: unknown): value is unknown[] => Array.isArray(value)
// an array where every item passes a check
export const isArrayOf = <T>(value: unknown, check: (item: unknown) => item is T): value is T[] =>
Array.isArray(value) && value.every(check)
isArrayOf(['a', 'b'], isString) // true
isArrayOf(['a', 1], isString) // false
Always Array.isArray, never instanceof Array. See Check if value is array for why.
Built-in objects
// a Date that holds a real date, not new Date('nonsense')
export const isValidDate = (value: unknown): value is Date =>
value instanceof Date && !Number.isNaN(value.getTime())
export const isRegExp = (value: unknown): value is RegExp => value instanceof RegExp
export const isMap = (value: unknown): value is Map<unknown, unknown> => value instanceof Map
export const isSet = (value: unknown): value is Set<unknown> => value instanceof Set
export const isError = (value: unknown): value is Error => value instanceof Error
new Date('nonsense') is still a Date, so instanceof Date alone isn’t enough. It’s an “Invalid Date” whose getTime() is NaN.
Error.isError(value) is a newer built-in that also works for errors from other realms, like iframes. Check browser support before you rely on it. See typing catch errors for using isError in a catch block.
Promises and iterables
// anything with a .then method: real promises and "thenables" from other libraries
export const isPromiseLike = (value: unknown): value is PromiseLike<unknown> =>
isObjectLike(value) && typeof (value as { then?: unknown }).then === 'function'
// arrays, strings, Maps, Sets, NodeLists…: anything for...of can loop over
export const isIterable = (value: unknown): value is Iterable<unknown> =>
value != null && typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] === 'function'
isIterable('abc') is true, because strings are iterable. Check !isString(value) as well if you want collections only.
Empty values
“Empty” depends on the type, so one helper handles each:
export const isEmpty = (value: unknown): boolean => {
if (value == null) return true
if (typeof value === 'string' || Array.isArray(value)) return value.length === 0
if (value instanceof Map || value instanceof Set) return value.size === 0
if (isPlainObject(value)) return Object.keys(value).length === 0
return false
}
isEmpty('') // true
isEmpty([]) // true
isEmpty({}) // true
isEmpty(new Map()) // true
isEmpty(0) // false: 0 is a value, not "empty"
isEmpty(new Date()) // false
isEmpty(' ') is false. Use value.trim() === '' if spaces should count as empty. For the single-type versions, see Check if JavaScript array is empty and Check if JavaScript object is empty.
Putting them together
The helpers combine into checks for whole objects:
type User = { id: number; name: string; tags: string[] }
export const isUser = (value: unknown): value is User =>
isPlainObject(value) &&
isFiniteNumber(value.id) &&
isString(value.name) &&
isArrayOf(value.tags, isString)
const data: unknown = await res.json()
if (!isUser(data)) throw new Error('Unexpected response')
data.tags.join(', ') // data: User
Past three or four fields, this is where a schema library pays off: you write the shape once and get both the check and the type.
The Object.prototype.toString trick
Older libraries check types with this, because it gives a precise name for built-ins:
const typeName = (value: unknown) => Object.prototype.toString.call(value).slice(8, -1)
typeName([]) // 'Array'
typeName(null) // 'Null'
typeName(new Date()) // 'Date'
typeName(/x/) // 'RegExp'
typeName(new Map()) // 'Map'
Any object can change its answer with Symbol.toStringTag, so it can be tricked. Prefer the specific helpers above.