I was debugging a webhook integration for a client when the payload arrived as any. The API docs said the body could contain a mixture of strings, numbers and nested objects, but the TypeScript compiler kept screaming that I was accessing properties on an unknown type. I spent an hour chasing down a any cast, then realized I could write a small set of type-guard functions and keep the code type-safe without pulling in a full-blown schema library.
Why unknown is safer than any
When you fetch JSON from an external service the safest type to annotate the response with is unknown. Unlike any, unknown forces you to perform a runtime check before you can treat the value as something more specific. The compiler will refuse any property access, method call or even a simple if (value === "test") unless you narrow the type first. This guardrail catches accidental misuse early and makes the intent of your code explicit.
The basic pattern: a type-guard function
A type-guard is just a function that returns a boolean and has a value is T return type. Inside the function you do whatever checks the runtime needs, then the compiler trusts the result.
function isString(value: unknown): value is string {
return typeof value === "string";
}
value: unknownis the input you receive from the outside world.value is stringtells TypeScript that when the function returnstrue,valuecan be treated as astring.- The body uses the native
typeofoperator, which is reliable for primitive types.
You can stack these guards to validate complex structures.
Validating objects with known shape
Suppose the webhook sends a payload like { id: number, name: string, meta?: { tags: string[] } }. First write guards for the inner pieces.
function isNumber(value: unknown): value is number {
return typeof value === "number" && !Number.isNaN(value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every(isString);
}
function isMeta(value: unknown): value is { tags: string[] } {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
return "tags" in obj && isStringArray(obj["tags"]);
}
- Each guard focuses on a single responsibility.
isMetafirst checks that the value is a non-null object, then verifies thetagsproperty using the previously definedisStringArray.
Now the top-level payload guard becomes straightforward.
function isPayload(value: unknown): value is {
id: number;
name: string;
meta?: { tags: string[] };
} {
if (typeof value !== "object" || value === null) return false;
const obj = value as Record<string, unknown>;
if (!"id" in obj || !"name" in obj) return false;
if (!isNumber(obj["id"])) return false;
if (!isString(obj["name"])) return false;
if ("meta" in obj && obj["meta"] !== undefined) {
if (!isMeta(obj["meta"])) return false;
}
return true;
}
- The guard mirrors the shape of the expected data.
- Optional fields are handled with an
incheck followed by a guard call. - Returning
truetells the compiler the originalunknownis now a fully typed object.
Using the guard in real code
async function handleWebhook(req: Request): Promise<Response> {
const body = await req.json(); // body is unknown
if (!isPayload(body)) {
return new Response("Invalid payload", { status: 400 });
}
// From here on `body` is typed correctly
console.log(`Received order ${body.id} for ${body.name}`);
if (body.meta) {
console.log("Tags:", body.meta.tags.join(", "));
}
return new Response("OK", { status: 200 });
}
- The early guard eliminates a whole class of runtime errors.
- No external schema parser is involved; the code stays lightweight and tree-shakable.
When to stop and pull in a library
If the JSON you receive has dozens of nested objects, unions, or conditional fields, hand-rolling guards can become noisy. At that point a schema library like Zod or Yup gives you a declarative DSL and automatic TypeScript inference. The trade-off is extra bundle size and a learning curve. My rule of thumb: if the total lines of guard code exceed about 150, consider a library.
Common pitfalls
- Forgetting to check
null,typeof nullisobject, so always guard against it explicitly. - Using
Array.isArraywithout element checks, an array of numbers will passArray.isArray, but you may need to ensure the element type. - Assuming
Object.keysorder, type guards should not rely on property order; JSON objects are unordered. - Over-using
anycasts inside guards, the only cast you need is the temporaryas Record<string, unknown>to index safely; avoid casting to the target type directly.
A tiny utility to reduce boilerplate
If you find yourself writing the same typeof checks repeatedly, a helper can keep things DRY.
function hasProp<T extends object, K extends PropertyKey>(
obj: T,
key: K
): obj is T & Record<K, unknown> {
return key in obj;
}
hasPropnarrows an object to one that definitely contains the given key.- Use it like
if (!hasProp(obj, "id")) return false;before callingisNumber(obj.id).
Testing your guards
Because guards are pure functions, unit testing them is trivial. Write a few positive and negative cases for each guard; a failing test will immediately highlight a logic hole you missed during manual debugging.
import { expect, test } from "vitest";
test("isStringArray accepts only string arrays", () => {
expect(isStringArray(["a", "b"])) .toBe(true);
expect(isStringArray(["a", 2])) .toBe(false);
expect(isStringArray("not an array")) .toBe(false);
});
- Keeping tests close to the guard definitions makes future refactors safe.
Performance considerations
Guard functions run synchronously and usually involve cheap typeof checks and a few Array.isArray calls. For payloads under a few megabytes the overhead is negligible. If you process massive streams, consider early-exit patterns (e.g., check required fields first) to avoid unnecessary deep checks.
FAQ
How do I validate a union type like string | number?
Create two separate guards (isString, isNumber) and combine them: function isStringOrNumber(v: unknown): v is string | number { return isString(v) || isNumber(v); }.
Can I reuse these guards for different APIs?
Yes. Write generic guards for primitives and arrays, then compose them for each API’s shape. The only API-specific part is the top-level guard that lists required keys.
What if the payload contains extra fields I don’t care about?
TypeScript’s excess property checks only apply to object literals. Since the payload is unknown at runtime, extra fields are ignored as long as the required ones pass. If you want to enforce a strict whitelist, add a loop that verifies Object.keys(obj).every(k => allowed.includes(k)).
Wrapping up
When you treat incoming JSON as unknown and let the compiler force you to write explicit runtime checks, you get both safety and clarity. A handful of well-named type-guard functions are enough for most webhook or API integrations, and they keep your bundle lean. If the data shape grows too complex, that’s the moment you reach for a dedicated schema library, but for the majority of everyday cases these native guards are more than sufficient.