Problem Solved

TypeScript · array type guard

Home › Courses › TypeScript › Problems › Type guard array
🔷 Problem · 5 min read

Type guard for array of objects.
Validate API data, safely.

A fetch returns JSON. You assume it's User[]. It might not be — missing fields, wrong types, null instead of an array. The types say User[]; the runtime says otherwise. A type guard validates the actual shape at the boundary, so the rest of your code can trust its types. This is the pattern every production API layer uses.

Short answer: Combine Array.isArray with an item guard: function isUserArray(x: unknown): x is User[] { return Array.isArray(x) && x.every(isUser); }. For reuse, build isArrayOf(arr, guard) — one helper for any item type. Use this at every API boundary. Never as User[] — a cast is a lie that compiles but crashes at runtime.

✅ Solution 1 — The array type guard

One guard for one type. The fundamental pattern.

The pattern: define a guard for the item type, then a guard for the array that combines Array.isArray with .every. The array guard's return type x is User[] tells TypeScript that if the function returns true, x is a User[] — safe to use everywhere inside the block.

array-guard.ts
// ─── Item type ───────────────────────────────
interface User {
id: string;
name: string;
email: string;
}

// ─── Step 1: guard for the item ──────────────
function isUser(x: unknown): x is User {
return (
typeof x === 'object' &&
x !== null &&
'id' in x &&
'name' in x &&
'email' in x &&
typeof (x as User).id === 'string' &&
typeof (x as User).name === 'string' &&
typeof (x as User).email === 'string'
);
}

// ─── Step 2: guard for the array ────────────
function isUserArray(x: unknown): x is User[] {
return Array.isArray(x) && x.every(isUser);
}

// ─── Usage ───────────────────────────────────
function processData(data: unknown): string {
if (!isUserArray(data)) {
throw new Error('Invalid data: expected User[]');
}
// data is User[] from here on
return data.map(u => u.name).join(', ');
}

// ─── Real API integration ────────────────────
async function fetchUsers(): Promise {
const res = await fetch('/api/users');
const data: unknown = await res.json(); // unknown, not any
if (!isUserArray(data)) {
throw new Error('API returned unexpected shape');
}
return data;
}

// ─── What happens with bad data ──────────────
const tests: unknown[] = [
[{ id: '1', name: 'Alice', email: 'a@x.com' }], // ✅
[{ id: '1', name: 'Alice' }], // ❌ missing email
[{ id: 1, name: 'Alice', email: 'a@x.com' }], // ❌ id is number
'not an array', // ❌ not array
null, // ❌ null
[], // ✅ empty array is valid
];

for (const t of tests) {
console.log(isUserArray(t) ? 'valid' : 'invalid');
}

console.log(processData(tests[0]));
function isUser(x) {
return (
typeof x === 'object' &&
x !== null &&
'id' in x &&
'name' in x &&
'email' in x &&
typeof x.id === 'string' &&
typeof x.name === 'string' &&
typeof x.email === 'string'
);
}

function isUserArray(x) {
return Array.isArray(x) && x.every(isUser);
}

function processData(data) {
if (!isUserArray(data)) {
throw new Error('Invalid data: expected User[]');
}
return data.map(u => u.name).join(', ');
}

async function fetchUsers() {
const res = await fetch('/api/users');
const data = await res.json();
if (!isUserArray(data)) {
throw new Error('API returned unexpected shape');
}
return data;
}

const tests = [
[{ id: '1', name: 'Alice', email: 'a@x.com' }],
[{ id: '1', name: 'Alice' }],
[{ id: 1, name: 'Alice', email: 'a@x.com' }],
'not an array',
null,
[],
];

for (const t of tests) {
console.log(isUserArray(t) ? 'valid' : 'invalid');
}

console.log(processData(tests[0]));

Two-level guard

isUser validates one item. isUserArray combines Array.isArray + .every(isUser). Each concern in its own function.

x is User[] narrows

The return type is the whole point. Inside the if, data becomes User[] — full autocomplete, full safety.

Empty arrays pass

[].every(fn) === true — an empty array is a valid User[]. Usually what you want. If you need non-empty, add x.length > 0.

Validate at the boundary

API responses are unknown. The guard converts unknown → User[] at one place. Everything downstream is typed and safe.

Every API fetch needs a type guard — this is not optional in production. Types describe what you expect; a type guard verifies what you got. Without one, your app trusts the server's response shape. That works until the server changes a field name, or returns an error object instead of an array, or sends null where you expected data — and then your app crashes in production on a Friday. The guard is 5 lines. The bugs it prevents cost hours. If you take one pattern from this problem-solvers hub and apply it everywhere, make it this one.

✅ Solution 2 — The generic isArrayOf helper

One helper, any item type. Write it once, use it forever.

The single-type guard is fine for one type. But if you have User[], Post[], Product[], Comment[] — you don't want to write isUserArray, isPostArray, isProductArray, isCommentArray. Write a generic helper once: isArrayOf(data, itemGuard) narrows to the correct array type for any item guard.

is-array-of.ts
// ─── The generic array guard ─────────────────
function isArrayOf(
arr: unknown,
itemGuard: (item: unknown) => item is T
): arr is T[] {
return Array.isArray(arr) && arr.every(itemGuard);
}

// ─── Item guards ─────────────────────────────
interface User { id: string; name: string }
interface Post { id: string; title: string }
interface Product { sku: string; price: number }

function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null
&& 'id' in x && 'name' in x
&& typeof (x as User).id === 'string'
&& typeof (x as User).name === 'string';
}

function isPost(x: unknown): x is Post {
return typeof x === 'object' && x !== null
&& 'id' in x && 'title' in x
&& typeof (x as Post).id === 'string'
&& typeof (x as Post).title === 'string';
}

function isProduct(x: unknown): x is Product {
return typeof x === 'object' && x !== null
&& 'sku' in x && 'price' in x
&& typeof (x as Product).sku === 'string'
&& typeof (x as Product).price === 'number';
}

// ─── One helper, any type ────────────────────
function processData(data: unknown) {
if (isArrayOf(data, isUser)) {
// data is User[]
return data.map(u => u.name);
}
if (isArrayOf(data, isPost)) {
// data is Post[]
return data.map(p => p.title);
}
if (isArrayOf(data, isProduct)) {
// data is Product[]
return data.map(p => p.sku);
}
throw new Error('Unknown data shape');
}

// ─── In API layer ────────────────────────────
async function fetchUsers(): Promise {
const res = await fetch('/api/users');
const data: unknown = await res.json();
if (!isArrayOf(data, isUser)) {
throw new Error('Invalid users response');
}
return data; // User[] — full safety from here
}

// ─── Compatible with primitive item guards ───
const isString = (x: unknown): x is string => typeof x === 'string';
const isNumber = (x: unknown): x is number => typeof x === 'number';

const maybeStrings: unknown = ['a', 'b'];
if (isArrayOf(maybeStrings, isString)) {
console.log(maybeStrings.map(s => s.toUpperCase()));
}

console.log(processData([{ id: '1', name: 'Alice' }]));
console.log(processData([{ id: '1', title: 'Hello' }]));
console.log(processData([{ sku: 'X', price: 10 }]));
function isArrayOf(arr, itemGuard) {
return Array.isArray(arr) && arr.every(itemGuard);
}

function isUser(x) {
return typeof x === 'object' && x !== null
&& 'id' in x && 'name' in x
&& typeof x.id === 'string'
&& typeof x.name === 'string';
}

function isPost(x) {
return typeof x === 'object' && x !== null
&& 'id' in x && 'title' in x
&& typeof x.id === 'string'
&& typeof x.title === 'string';
}

function isProduct(x) {
return typeof x === 'object' && x !== null
&& 'sku' in x && 'price' in x
&& typeof x.sku === 'string'
&& typeof x.price === 'number';
}

function processData(data) {
if (isArrayOf(data, isUser)) {
return data.map(u => u.name);
}
if (isArrayOf(data, isPost)) {
return data.map(p => p.title);
}
if (isArrayOf(data, isProduct)) {
return data.map(p => p.sku);
}
throw new Error('Unknown data shape');
}

async function fetchUsers() {
const res = await fetch('/api/users');
const data = await res.json();
if (!isArrayOf(data, isUser)) {
throw new Error('Invalid users response');
}
return data;
}

const isString = (x) => typeof x === 'string';
const isNumber = (x) => typeof x === 'number';

const maybeStrings = ['a', 'b'];
if (isArrayOf(maybeStrings, isString)) {
console.log(maybeStrings.map(s => s.toUpperCase()));
}

console.log(processData([{ id: '1', name: 'Alice' }]));
console.log(processData([{ id: '1', title: 'Hello' }]));
console.log(processData([{ sku: 'X', price: 10 }]));

Generic + predicate

itemGuard: (item: unknown) => item is T — the parameter type is a guard for T. The return type arr is T[] narrows the whole array.

One helper, many types

isArrayOf(data, isUser) → User[]. isArrayOf(data, isPost) → Post[]. No duplication per type.

Primitives work too

isArrayOf(data, isString) → string[]. The generic doesn't care whether T is an object or primitive.

Same pattern, more reuse

The typed guard library (Zod, Valibot) does exactly this under the hood. Once you have isArrayOf, most API validation is a two-line guard.

✅ Solution 3 — Where to place guards in a real app

The API layer pattern — one guard, everywhere is safe.

The right place for the guard is at the API boundary — where data enters your app. Not in every component. Not in every reducer. One place: the fetch wrapper. After that, everything downstream is typed and validated. This is the pattern every production codebase uses.

api-layer.ts
// ─── The core helpers ────────────────────────
function isArrayOf(
arr: unknown,
itemGuard: (item: unknown) => item is T
): arr is T[] {
return Array.isArray(arr) && arr.every(itemGuard);
}

function isObjectWithextends Record<string, unknown>>(
x: unknown,
shape: { [K in keyof T]: (v: unknown) => v is T[K] }
): x is T {
if (typeof x !== 'object' || x === null) return false;
for (const k of Object.keys(shape) as (keyof T)[]) {
if (!shape[k]((x as Record<string, unknown>)[k as string])) return false;
}
return true;
}

// ─── Type definitions ────────────────────────
interface User {
id: string;
name: string;
email: string;
}

// ─── Guards built from the helpers ───────────
const isString = (x: unknown): x is string => typeof x === 'string';

const isUser = (x: unknown): x is User =>
isObjectWith(x, {
id: isString,
name: isString,
email: isString
});

// ─── The API client ──────────────────────────
class ApiClient {
async fetchUsers(): Promise {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(HTTP ${res.status}`);

const data: unknown = await res.json();
if (!isArrayOf(data, isUser)) {
throw new Error('Invalid /api/users response');
}
return data;
}

async fetchUser(id: string): Promise {
const res = await fetch(/api/users/${id}HTTP ${res.status});

const data: unknown = await res.json();
if (!isUser(data)) {
throw new Error(Invalid /api/users/${id} response`);
}
return data;
}
}

// ─── Everywhere else is typed and safe ───────
//
// function UserList() {
// const [users, setUsers] = useState([]);
// useEffect(() => {
// api.fetchUsers().then(setUsers); // User[] guaranteed
// }, []);
// return 
    {users.map(u =>
  • {u.name}
  • )}
; // } // // No guards here. No casts. Just typed data. // The validation happened at the boundary.
const api = new ApiClient(); // ─── The rules ─────────────────────────────── // // 1. API responses are unknown, not any. // 2. Guard at the fetch wrapper, not in components. // 3. Validate both single objects and arrays. // 4. Throw on invalid — don't silently return []. // 5. Inside the app, trust the types — that's // what the guard at the boundary is for. console.log(isArrayOf([{ id: '1', name: 'A', email: 'a@x.com' }], isUser)); console.log(isUser({ id: '1', name: 'A', email: 'a@x.com' }));
function isArrayOf(arr, itemGuard) {
return Array.isArray(arr) && arr.every(itemGuard);
}

function isObjectWith(x, shape) {
if (typeof x !== 'object' || x === null) return false;
for (const k of Object.keys(shape)) {
if (!shapek) return false;
}
return true;
}

const isString = (x) => typeof x === 'string';

const isUser = (x) =>
isObjectWith(x, {
id: isString,
name: isString,
email: isString
});

class ApiClient {
async fetchUsers() {
const res = await fetch('/api/users');
if (!res.ok) throw new Error(HTTP ${res.status}/api/users/${id});
if (!res.ok) throw new Error(HTTP ${res.status}Invalid /api/users/${id} response);
}
return data;
}
}

const api = new ApiClient();

console.log(isArrayOf([{ id: '1', name: 'A', email: 'a@x.com' }], isUser));
console.log(isUser({ id: '1', name: 'A', email: 'a@x.com' }));

Guard at the boundary

Validate at the fetch wrapper. Everything downstream trusts the type. One guard, not one per component.

Throw, don't default

Invalid data should throw. Returning [] hides the bug. Fix the backend or fix the guard — don't paper over it.

isObjectWith helper

A shape-based guard: pass property guards, get an object guard. Composition without a library. { id: isString, name: isString }.

Layered safety

HTTP status → guard → typed return. Each layer catches a different class of error. Together, the app trusts its types.

Don't guard inside every component. Guarding User[] in 15 different components is repetition, slow, and drifts. Guard once at the boundary — the fetch wrapper — and trust the type everywhere else. If the shape is wrong, the app fails fast, at one place, with a clear error. If the shape is right, every component gets a well-typed User[] and never needs to check again. This is the single most important architectural decision for a typed app.

⚠️ Common mistakes

The array guard bugs that cost real time.

1. Using as User[] instead of a guard

A cast compiles but doesn't check at runtime. If the API shape is wrong, the app crashes. Fix: use a type guard, or use as unknown + guard.

2. Guarding the wrong level

isArrayOf(data, isUser) — good. data.every(isUser) alone — crashes if data isn't an array. Fix: include Array.isArray.

3. Forgetting null check on objects

typeof null === 'object' — isUser(null) might pass without x !== null. Fix: always check x !== null first.

4. Guarding in every component

Repetition, drift, and confusing error handling. Fix: guard at the API layer only. Downstream is typed.

5. Using every() on an empty array

[].every(fn) === true — sometimes not what you want. Fix: add x.length > 0 if you need non-empty.

6. Guessing the shape with in alone

'name' in x doesn't check the type. Fix: also check typeof x.name === 'string'.

7. Writing guards per type instead of generic

isUserArray, isPostArray, isProductArray — repetition. Fix: one isArrayOf(item, guard).

8. Not throwing on invalid data

Returning [] or undefined hides the bug. Fix: throw. If the shape is wrong, the caller needs to know.

❓ Frequently asked

How do I type guard an array of objects in TypeScript?

Use Array.isArray combined with an item-level type guard: function isUserArray(x: unknown): x is User[] { return Array.isArray(x) && x.every(isUser); }. The Array.isArray narrows x to any[], then every() applies isUser to each element.

What is a generic isArrayOf helper?

A reusable array guard that takes an item guard: function isArrayOf(arr: unknown, guard: (item: unknown) => item is T): arr is T[] { return Array.isArray(arr) && arr.every(guard); }. Then isArrayOf(data, isUser) narrows to User[], isArrayOf(data, isPost) narrows to Post[].

Why can't I just use as User[]?

as is a cast — it disables checking and tells TypeScript 'trust me.' It doesn't verify at runtime. If the API returns an array of objects missing the name field, as User[] compiles fine but crashes at runtime when you access .name. A type guard actually validates the shape at runtime.

Should I use Zod or Valibot instead of manual type guards?

For simple shapes, manual guards are fine and have zero dependencies. For complex nested validation, error messages, and coercions, use Zod, Valibot, or ArkType — they generate the guard AND the type from one schema. Manual guards still matter for: library internals, minimal dependency projects, and cases where the runtime check is trivial.

🔗 Related

← All TypeScript problems
Problem solved · TypeScript