About this course
<p>Picture this: you hit an API endpoint, and you get an API response back. You pass the data straight into your application, and everything looks fine in development. Your mock data is clean, your types line up, and everything checks out.</p>
<p>Then your code hits production. A field from the API endpoint comes back as <code>null</code> instead of a string. You were expecting an array, and it comes back as <code>undefined</code>, expecting an object and receiving a <code>number</code>. Suddenly, you're faced with an error screen, a crashed UI, or worse, silent data corruption that nobody notices until a user complains.</p>
<p>This is a common and preventable bug in JavaScript development. The fix doesn't require a third-party library or a complete architecture overhaul. It requires a small set of utility functions and the discipline to use them when needed.</p>
<p>This article shows you how to build a resilient application using four TypeScript guard utilities that'll make your codebase more reliable: <code>safeArray</code>, <code>safeString</code>, <code>safeNumber</code>, and <code>safeObject</code>. The utilities are framework-agnostic, so whether you're working in React, plain JavaScript, or anything in between, you can drop them straight into your codebase.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-problem">The Problem</a></p>
</li>
<li><p><a href="#heading-why-this-problem-exists">Why This Problem Exists</a></p>
</li>
<li><p><a href="#heading-the-solution-safe-access-utilities">The Solution: Safe Access Utilities</a></p>
</li>
<li><p><a href="#heading-how-each-utility-works">How Each Utility Works</a></p>
</li>
<li><p><a href="#heading-how-to-use-them-in-practice">How to Use Them in Practice</a></p>
</li>
<li><p><a href="#heading-best-practices">Best Practices</a></p>
</li>
<li><p><a href="#heading-things-to-avoid">Things to Avoid</a></p>
</li>
<li><p><a href="#heading-bonus-combine-them-into-a-safedata-helper">Bonus: Combine Them into a safeData Helper</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before diving in, you should have:</p>
<ul>
<li><p>A working knowledge of TypeScript. You don't need to be an expert, but you should be comfortable with types, interfaces, and generics</p>
</li>
<li><p>Familiarity with JavaScript and its built-in type-checking methods, like <code>typeof</code> and <code>Array.isArray</code>.</p>
</li>
</ul>
<h2 id="heading-the-problem">The Problem</h2>
<p>JavaScript is a loosely typed language. It will let you call <code>.map()</code> on something that isn't an array, access properties on <code>null</code>, and do arithmetic with <code>NaN</code>. All of this, without throwing an error until it's too late. The language doesn't push back. It just breaks quietly.</p>
<p>TypeScript helps, but only up to a point. It checks types at compile time, not at runtime. So when external data arrives from an API, a form submission, local storage, or a third-party SDK, TypeScript has already left the building. Whatever your interface says, the actual value at runtime is whatever JavaScript received.</p>
<p>Here's what that looks like in practice:</p>
<pre><code class="language-typescript">// This looks fine. It is not fine.
type User = {
id: number;
name: string;
tags: string[];
};
function displayUser(user: User) {
const upperName = user.name.toUpperCase();
const tagList = user.tags.map((tag) => `#${tag}`);
return { upperName, tagList };
}
</code></pre>
<p>If <code>user.name</code> comes back as <code>null</code>, calling <code>.toUpperCase()</code> crashes your application. If <code>user.tags</code> is <code>undefined</code>, calling <code>.map()</code> crashes your application, too. Both scenarios are entirely possible when you're consuming a real API, and TypeScript won't warn you because you told it to trust the type.</p>
<p>Wait! I hear you saying, "I can use <code>optional chaining</code> to stop my app from crashing". This is correct, like the example below:</p>
<pre><code class="language-typescript">// This looks better. But...
type User = {
id: number;
name: string;
tags: string[];
};
function displayUser(user: User) {
const upperName = user?.name?.toUpperCase?.();
const tagList = (user?.tags || [])?.map((tag) => `#${tag}`);
return { upperName, tagList };
}
</code></pre>
<p>But there are issues with the above approach. Firstly, <code>upperName</code> will return <code>undefined</code> if <code>user.name</code> isn't a string. Secondly, the <code>user?.tag || []</code> guards for <code>undefined</code> and <code>null</code> values alone. What if an object gets returned? <code>{...}?.map(...)</code>? Do you see the real issue now?</p>
<p>So <code>user?.name?.toUpperCase?.()</code> safely handles cases where <code>user</code>, <code>name</code>, or even <code>toUpperCase</code> itself might not exist. This is handy when dealing with uncertain data shapes, but it doesn't handle data mismatch.</p>
<h2 id="heading-why-this-problem-exists">Why This Problem Exists</h2>
<p>The blame sits squarely with JavaScript's type system, or rather, its lack of one.</p>
<p>JavaScript has a handful of primitive types and a few rules that seem reasonable until you look at them closely. For example, <code>typeof null</code> returns <code>"object"</code>, <code>typeof []</code> also returns <code>"object"</code>, and <code>typeof NaN</code> returns <code>"number"</code>. These aren't edge cases. They're the language.</p>
<p>Here's a quick illustration of how easily JavaScript misleads you:</p>
<pre><code class="language-javascript">typeof null; // "object" — not "null"
typeof []; // "object" — not "array"
typeof NaN; // "number" — NaN is technically a number
Array.isArray([]); // true — this is the correct check
isNaN("hello"); // true — because "hello" coerces to NaN
Number.isNaN("hello"); // false — this is the correct check
</code></pre>
<p>TypeScript layers a static type system on top of this, catching many mistakes before your code runs. But static analysis only works on code you've already written. The moment data crosses the network boundary or comes from <code>localStorage</code>, a URL parameter, a third-party script, or any source outside your codebase, TypeScript's guarantees stop.</p>
<p>When you write something like this:</p>
<pre><code class="language-typescript">const data = await response.json() as User;
</code></pre>
<p>You're not validating anything. You're telling the TypeScript compiler, "I promise this is a <code>User</code>" . The compiler accepts that promise and stops checking. But if the API returns <code>null</code> for a field, sends a string where you expected a number, or omits a property entirely, JavaScript will proceed anyway and your code will break at the first operation that assumes otherwise.</p>
<p>This gap between "what TypeScript thinks the data is" and "what the data actually is at runtime" is where most production data bugs live. The fix is to stop trusting the type assertion and start validating the data yourself.</p>
<h2 id="heading-the-solution-safe-access-utilities">The Solution: Safe Access Utilities</h2>
<p>The fix is to validate data at the boundary. The moment the expected data enters your application, check it before you pass it anywhere else.</p>
<p>These four functions do exactly that:</p>
<pre><code class="language-typescript">export function safeArray<T>(prop: unknown): T[] {
if (Array.isArray(prop)) {
return prop as T[];
} else {
return [] as T[];
}
}
export function safeString(prop: unknown, fallback = ""): string {
if (typeof prop === "string") {
return prop;
} else {
return fallback;
}
}
export function safeNumber(prop: unknown, fallback = 0): number {
if (typeof prop === "number" && !isNaN(prop)) {
return prop;
} else {
return fallback;
}
}
export function safeObject<T extends object>(
prop: unknown,
fallback = {} as T,
): T {
if (prop !== null && typeof prop === "object" && !Array.isArray(prop)) {
return prop as T;
}
return fallback;
}
</code></pre>
<p>Each function accepts <code>unknown</code>, which forces you to validate the value before using it. Each one returns a safe default if the input isn't what you expected. No crashes, no silent <code>undefined</code>, and no cryptic runtime errors.</p>
<p>You can drop these into any JavaScript or TypeScript project: React app, a Node.js API, a vanilla TypeScript module, or wherever you're handling external data.</p>
<h2 id="heading-how-each-utility-works">How Each Utility Works</h2>
<h3 id="heading-safearray"><code>safeArray</code></h3>
<pre><code class="language-typescript">export function safeArray<T>(prop: unknown): T[] {
if (Array.isArray(prop)) {
return prop as T[];
} else {
return [] as T[];
}
}
</code></pre>
<p>This checks whether <code>prop</code> is actually an array using <code>Array.isArray</code>. If it is, you get it back typed as <code>T[]</code>. If it's anything other than an array like <code>null</code>, <code>undefined</code>, a string, or whatever, you get back an empty array.</p>
<p>This matters because of the JavaScript quirk you saw above: <code>typeof []</code> returns <code>"object"</code>, which means a naive <code>typeof</code> check wouldn't catch this. <code>Array.isArray</code> handles it correctly.</p>
<h3 id="heading-safestring"><code>safeString</code></h3>
<pre><code class="language-typescript">export function safeString(prop: unknown, fallback = ""): string {
if (typeof prop === "string") {
return prop;
} else {
return fallback;
}
}
</code></pre>
<p>This function uses <code>typeof</code> to confirm that the value is a string. The optional <code>fallback</code> parameter lets you specify a meaningful default. For example, <code>"Unknown"</code> instead of an empty string, when displaying a user's name.</p>
<h3 id="heading-safenumber"><code>safeNumber</code></h3>
<pre><code class="language-typescript">export function safeNumber(prop: unknown, fallback = 0): number {
if (typeof prop === "number" && !isNaN(prop)) {
return prop;
} else {
return fallback;
}
}
</code></pre>
<p>The key detail here is <code>!isNaN(prop)</code>. Because <code>typeof NaN === "number"</code> is true in JavaScript, skipping this check means you could return <code>NaN</code> and cause downstream calculation failures. This function guards against that.</p>
<h3 id="heading-safeobject"><code>safeObject</code></h3>
<pre><code class="language-typescript">export function safeObject<T extends object>(
prop: unknown,
fallback = {} as T,
): T {
if (prop !== null && typeof prop === "object" && !Array.isArray(prop)) {
return prop as T;
}
return fallback;
}
</code></pre>
<p>This one requires three conditions due to JavaScript's quirks. <code>typeof null === "object"</code> is true. <code>typeof [] === "object"</code> is also true. So this function explicitly excludes both. What you get back is guaranteed to be a plain object and nothing else.</p>
<h2 id="heading-how-to-use-them-in-practice">How to Use Them in Practice</h2>
<h3 id="heading-normalising-api-responses-plain-typescript">Normalising API Responses (Plain TypeScript)</h3>
<p>The best place to use these utilities is in the function that processes your API response before the data reaches any other part of your application. This works the same way, whether in a React app, a Node.js service, or a plain TypeScript module.</p>
<pre><code class="language-typescript">// lib/users.ts
import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";
type User = {
id: number;
name: string;
email: string;
tags: string[];
};
function normaliseUser(raw: unknown): User {
const obj = safeObject<Record<string, unknown>>(raw);
return {
id: safeNumber(obj.id),
name: safeString(obj.name, "Unknown User"),
email: safeString(obj.email),
tags: safeArray<string>(obj.tags),
};
}
async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data = await response.json();
return normaliseUser(data);
}
</code></pre>
<p>By the time your code receives the <code>User</code> object, every field is guaranteed to be the type you declared. Nothing downstream has to wonder whether <code>name</code> might be <code>null</code> or <code>tags</code> might be <code>undefined</code>.</p>
<h3 id="heading-in-a-react-component-defensive-rendering">In a React Component (Defensive Rendering)</h3>
<p>Sometimes you receive data directly in a component, from props, context, or a query result, and you don't control normalisation upstream. In that case, wrap the values at the point of use.</p>
<pre><code class="language-typescript">import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";
type ProductProps = {
product: unknown;
};
function ProductCard({ product }: ProductProps) {
const p = safeObject<Record<string, unknown>>(product);
const name = safeString(p.name, "Unnamed Product");
const price = safeNumber(p.price);
const tags = safeArray<string>(p.tags);
return (
<div className="product-card">
<h3>{name}</h3>
<p>${price.toFixed(2)}</p>
<ul>
{tags.map((tag) => (
<li key={tag}>{tag}</li>
))}
</ul>
</div>
);
}
</code></pre>
<p>Even if <code>product</code> arrives as <code>null</code> or a completely unexpected shape, this component will render a fallback state instead of crashing.</p>
<h3 id="heading-with-react-query">With React Query</h3>
<p>If you're using React Query, you can normalise data inside the <code>select</code> option, which transforms the raw API response before it reaches your component.</p>
<pre><code class="language-typescript">import { useQuery } from "@tanstack/react-query";
import { safeArray, safeString, safeNumber, safeObject } from "@/utils/safe";
type Order = {
id: number;
status: string;
total: number;
items: string[];
};
function normaliseOrder(raw: unknown): Order {
const obj = safeObject<Record<string, unknown>>(raw);
return {
id: safeNumber(obj.id),
status: safeString(obj.status, "pending"),
total: safeNumber(obj.total),
items: safeArray<string>(obj.items),
};
}
function useOrder(orderId: string) {
return useQuery({
queryKey: ["order", orderId],
queryFn: () =>
fetch(`/api/orders/${orderId}`).then((res) => res.json()),
select: normaliseOrder,
});
}
</code></pre>
<p>The <code>select</code> callback runs after the query resolves and before the data is cached. Your <code>useOrder</code> hook always returns a properly shaped <code>Order</code>, regardless of what the API actually sent back.</p>
<h3 id="heading-with-a-react-context-provider">With a React Context Provider</h3>
<p>Context is a place where unsafe data can silently propagate through your entire component tree. Normalise it at the provider level so every consumer is protected.</p>
<pre><code class="language-typescript">import { createContext, useContext, useEffect, useState } from "react";
import { safeArray, safeString, safeObject } from "@/utils/safe";
type AppConfig = {
theme: string;
features: string[];
};
const defaultConfig: AppConfig = {
theme: "light",
features: [],
};
const ConfigContext = createContext<AppConfig>(defaultConfig);
function ConfigProvider({ children }: { children: React.ReactNode }) {
const [config, setConfig] = useState<AppConfig>(defaultConfig);
useEffect(() => {
fetch("/api/config")
.then((res) => res.json())
.then((raw: unknown) => {
const obj = safeObject<Record<string, unknown>>(raw);
setConfig({
theme: safeString(obj.theme, "light"),
features: safeArray<string>(obj.features),
});
});
}, []);
return (
<ConfigContext.Provider value={config}>{children}</ConfigContext.Provider>
);
}
export function useConfig() {
return useContext(ConfigContext);
}
</code></pre>
<p>One normalisation step at the provider level protects every component that consumes the context.</p>
<h3 id="heading-in-a-nodejs-api-route">In a Node.js API Route</h3>
<p>These utilities are just as useful on the backend. If your Node.js API receives a request body, you can't trust that the client sent what you expected. Validate it at the point of entry.</p>
<pre><code class="language-typescript">// routes/orders.ts (Express)
import { safeArray, safeString, safeNumber, safeObject } from "../utils/safe";
type OrderPayload = {
userId: number;
notes: string;
itemIds: number[];
};
function parseOrderPayload(raw: unknown): OrderPayload {
const obj = safeObject<Record<string, unknown>>(raw);
return {
userId: safeNumber(obj.userId),
notes: safeString(obj.notes),
itemIds: safeArray<number>(obj.itemIds),
};
}
app.post("/orders", (req, res) => {
const payload = parseOrderPayload(req.body);
if (!payload.userId) {
return res.status(400).json({ error: "userId is required" });
}
// proceed with validated payload
});
</code></pre>
<p>The same four utilities, the same pattern. Just a different runtime environment.</p>
<h2 id="heading-best-practices">Best Practices</h2>
<p>As with anything, there are some best practices that'll help you use these utilities well and correctly.</p>
<p>First, normalise at the boundary, not inside every function. The best place to call these utilities is in your data-fetching layer, API handlers, or integration points, just once, before the data spreads. If you're calling <code>safeString</code> in five different places for the same field, that's a sign the normalisation belongs upstream.</p>
<p>Second, use meaningful fallbacks. The default fallbacks (empty string, <code>0</code>, empty array, and empty object) are safe, but sometimes misleading. For a user's display name, <code>safeString(name, "Anonymous")</code> is more informative than <code>safeString(name)</code>. Think about what makes sense for each field in your domain.</p>
<p>Third, keep your type definitions honest. If a field can realistically be <code>null</code> or <code>undefined</code> from your data source, reflect that in your types and use these utilities to handle it. Typing a field as <code>string</code> when it might be <code>null</code> just papers over the problem. These utilities work best when your types reflect the reality of what you receive.</p>
<p>Finally, create a normalisation module. Put all your normaliser functions in one place, for example, <code>src/lib/normalise.ts</code>. This keeps the defensive logic centralised, easy to test, and out of your application logic.</p>
<h2 id="heading-things-to-avoid">Things to Avoid</h2>
<p>Likewise, there are some practices you should avoid.</p>
<p>First, don't use these utilities as a substitute for a proper data contract. If your entire codebase is wrapping every value in <code>safeString</code> because your data sources are wildly inconsistent, the real fix is a contract, an OpenAPI spec, a shared schema, Zod validation, or at minimum, documented response shapes. These utilities handle edge cases and runtime surprises, not systemic chaos.</p>
<p>Second, don't skip the <code>safeObject</code> wrapper. It's tempting to cast straight to <code>any</code> and access properties directly. Avoid this. The <code>as any</code> cast defeats TypeScript entirely, and accessing properties on an <code>unknown</code> value will cause a compile error anyway. Use <code>safeObject</code> to unwrap the value first, then access its fields safely.</p>
<p>Next, don't chain these utilities without extracting intermediate values. Something like <code>safeString(safeArray(raw)[0])</code> might seem compact, but it's harder to read and debug. Extract intermediate values into clearly named variables instead.</p>
<p>And finally, don't skip validation just because you control the data source. "I wrote the API, so I know what it returns" is a reasonable position right up until a schema migration, a nullable column addition, or an unconsidered edge case proves otherwise. Trust the utilities, not your memory.</p>
<h2 id="heading-bonus-combine-them-into-a-safedata-helper">Bonus: Combine Them into a <code>safeData</code> Helper</h2>
<p>If you find yourself calling all four utilities together frequently, which you will once you start normalising API responses consistently, you can compose them into a single fluent helper.</p>
<pre><code class="language-typescript">// utils/safeData.ts
import { safeArray, safeString, safeNumber, safeObject } from "./safe";
type SafeDataAccessors = {
string: (key: string, fallback?: string) => string;
number: (key: string, fallback?: number) => number;
array: <T>(key: string) => T[];
object: <T extends object>(key: string, fallback?: T) => T;
};
export function safeData(raw: unknown): SafeDataAccessors {
const obj = safeObject<Record<string, unknown>>(raw);
return {
string: (key, fallback = "") => safeString(obj[key], fallback),
number: (key, fallback = 0) => safeNumber(obj[key], fallback),
array: <T>(key: string) => safeArray<T>(obj[key]),
object: <T extends object>(key: string, fallback = {} as T) =>
safeObject<T>(obj[key], fallback),
};
}
</code></pre>
<p>Your normalisation functions then read cleanly, whether you're in a React hook, an Express route, or anywhere else:</p>
<pre><code class="language-typescript">import { safeData } from "@/utils/safeData";
function normaliseUser(raw: unknown) {
const d = safeData(raw);
return {
id: d.number("id"),
name: d.string("name", "Unknown User"),
email: d.string("email"),
tags: d.array<string>("tags"),
};
}
</code></pre>
<p>This is a thin abstraction: no magic, just less repetition. Use it if your normalisation functions are getting verbose. Skip it if the direct utility calls are clear enough for your team.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>JavaScript's loose type system and TypeScript's compile-time-only guarantees leave a gap at every data boundary. External data — from APIs, request bodies, local storage, third-party scripts — arrives at runtime with no guarantee it matches the shape you declared. These four utilities close that gap.</p>
<p><code>safeArray</code>, <code>safeString</code>, <code>safeNumber</code>, and <code>safeObject</code> each accept <code>unknown</code>, validate the actual type, and return a safe fallback if the value isn't what you expected. They work in React components, Node.js routes, custom hooks, context providers, and any other JavaScript or TypeScript context where data enters your application.</p>
<p>The pattern is simple: validate at the boundary, trust inside. Normalise your data once, at the point it enters your codebase, and everything downstream can focus on its actual job instead of defending against bad inputs.</p>