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: unknown is the input you receive from the outside world.
  • value is string tells TypeScript that when the function returns true, value can be treated as a string.
  • The body uses the native typeof operator, 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.
  • isMeta first checks that the value is a non-null object, then verifies the tags property using the previously defined isStringArray.

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 in check followed by a guard call.
  • Returning true tells the compiler the original unknown is 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 null is object, so always guard against it explicitly.
  • Using Array.isArray without element checks, an array of numbers will pass Array.isArray, but you may need to ensure the element type.
  • Assuming Object.keys order, type guards should not rely on property order; JSON objects are unordered.
  • Over-using any casts inside guards, the only cast you need is the temporary as 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;
}
  • hasProp narrows an object to one that definitely contains the given key.
  • Use it like if (!hasProp(obj, "id")) return false; before calling isNumber(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.