Zap Studio

Validation

Runtime response validation using Standard Schema in @zap-studio/fetch.

@zap-studio/fetch uses Standard Schema for runtime validation, which means it works with any schema library that implements the Standard Schema specification.

Why Validation Matters

APIs change. Without runtime validation, you might get data that doesn't match your TypeScript types, causing subtle bugs that are hard to track down:

// Without validation
const user = await fetch("/api/users/1").then((r) => r.json()) as User;
// What if the API returns { id: "123" } instead of { id: 123 }?
// TypeScript thinks id is a number, but it's actually a string!
user.id + 1; // "1231" instead of 124 😱

With @zap-studio/fetch, you get runtime validation that catches these issues immediately:

// With validation
const user = await api.get("/api/users/1", UserSchema);
// If the API returns { id: "123" }, you get a ValidationError
// instead of silent type mismatch

Supported Schema Libraries

Any library implementing Standard Schema v1 is supported:

  • Zod — The most popular TypeScript-first schema library
  • Valibot — Smaller bundle size alternative
  • ArkType — 1:1 TypeScript syntax
  • TypeBox — JSON Schema compatible
  • And more...

Using Different Schema Libraries

Zod

import { z } from "zod";
import { api } from "@zap-studio/fetch";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().datetime(),
});

const user = await api.get("/users/1", UserSchema);

Valibot

import * as v from "valibot";
import { api } from "@zap-studio/fetch";

const UserSchema = v.object({
  id: v.number(),
  name: v.string(),
  email: v.pipe(v.string(), v.email()),
  createdAt: v.pipe(v.string(), v.isoTimestamp()),
});

const user = await api.get("/users/1", UserSchema);

ArkType

import { type } from "arktype";
import { api } from "@zap-studio/fetch";

const UserSchema = type({
  id: "number",
  name: "string",
  email: "email",
  createdAt: "string",
});

const user = await api.get("/users/1", UserSchema);

The standardValidate Helper

For standalone validation needs, use the standardValidate helper:

import { standardValidate } from "@zap-studio/fetch/validator";
import { z } from "zod";

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
});

// Throwing validation (default)
const user = await standardValidate(UserSchema, data, true);
// Returns validated data or throws ValidationError

// Non-throwing validation
const result = await standardValidate(UserSchema, data, false);
// Returns { value: T } or { issues: Issue[] }

if (result.issues) {
  console.error("Validation failed:", result.issues);
} else {
  console.log("Valid user:", result.value);
}

isStandardSchema Type Guard

Check if a value is a Standard Schema:

import { isStandardSchema } from "@zap-studio/fetch/validator";

const schema = z.object({ name: z.string() });

if (isStandardSchema(schema)) {
  // TypeScript knows schema is StandardSchemaV1
  const result = schema["~standard"].validate(data);
}

Validation Flow

When you pass a schema to $fetch or api.* methods:

  1. The HTTP request is made
  2. The response JSON is parsed
  3. The data is validated against your schema
  4. If valid, the typed data is returned
  5. If invalid and throwOnValidationError: true, a ValidationError is thrown
  6. If invalid and throwOnValidationError: false, a Result object is returned
// Simplified internal flow
const response = await fetch(url, options);
const rawData = await response.json();
const validatedData = await standardValidate(schema, rawData, throwOnValidationError);
return validatedData;

Handling Validation Results

Throwing Mode (Default)

import { ValidationError } from "@zap-studio/fetch/errors";

try {
  const user = await api.get("/users/1", UserSchema);
  // user is fully typed: { id: number; name: string; email: string; }
} catch (error) {
  if (error instanceof ValidationError) {
    for (const issue of error.issues) {
      console.error(`${issue.path?.join(".")}: ${issue.message}`);
    }
  }
}

Non-Throwing Mode

const result = await api.get("/users/1", UserSchema, {
  throwOnValidationError: false,
});

if (result.issues) {
  result.issues.forEach((issue) => {
    console.error(`${issue.path?.join(".")}: ${issue.message}`);
  });
} else {
  console.log("User name:", result.value.name);
}

Type Inference

Types are automatically inferred from your schema:

const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  roles: z.array(z.enum(["admin", "user"])),
});

const user = await api.get("/users/1", UserSchema);
// TypeScript infers:
// {
//   id: number;
//   name: string;
//   email: string;
//   roles: ("admin" | "user")[];
// }

Best Practices

1. Define Schemas Once, Reuse Everywhere

// schemas/user.ts
import { z } from "zod";

export const UserSchema = z.object({
  id: z.number(),
  name: z.string(),
  email: z.string().email(),
  createdAt: z.string().transform((s) => new Date(s)),
});

export type User = z.infer<typeof UserSchema>;

// api/users.ts
import { api } from "@zap-studio/fetch";
import { UserSchema, type User } from "@/schemas/user";

export async function getUser(id: number): Promise<User> {
  return api.get(`/users/${id}`, UserSchema);
}

2. Use Strict Schemas

Validate exactly what you expect:

// Too loose - accepts any extra fields
const LooseSchema = z.object({ id: z.number() });

// Better - rejects unknown fields
const StrictSchema = z.object({ id: z.number() }).strict();

3. Handle Validation Errors Gracefully

APIs can change unexpectedly:

async function getUser(id: string) {
  try {
    return await api.get(`/users/${id}`, UserSchema);
  } catch (error) {
    if (error instanceof ValidationError) {
      // Log for debugging but don't crash
      console.error("API response changed:", error.issues);
      return null;
    }
    throw error;
  }
}

4. Use Schema Transforms

Parse and transform data in your schema:

const DateSchema = z.string().transform((s) => new Date(s));

const PriceSchema = z.number().transform((cents) => ({
  cents,
  dollars: cents / 100,
  formatted: `$${(cents / 100).toFixed(2)}`,
}));

5. Compose Schemas

Build complex schemas from simpler parts:

const AddressSchema = z.object({
  street: z.string(),
  city: z.string(),
  country: z.string(),
});

const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  address: AddressSchema.optional(),
});

const OrderSchema = z.object({
  id: z.string(),
  user: UserSchema,
  shippingAddress: AddressSchema,
  billingAddress: AddressSchema.optional(),
});
Edit on GitHub

Last updated on

On this page