Schema Validation

Validate route inputs, shared function inputs, and structured outputs with StandardSchema support

Use this page when you maintain code that already uses @agentuity/schema. New v3 examples should generally use Zod, ArkType, or Valibot because Agentuity accepts Standard Schema-compatible validators.

StandardSchema Support

@agentuity/schema implements the StandardSchemaV1 interface. That makes schemas usable at app boundaries, service helpers, AI SDK structured output calls, and any code that accepts StandardSchema validators.

Type Inference

Derive TypeScript types from the schema instead of writing a second interface.

import { s } from '@agentuity/schema';
 
const SearchInputSchema = s.object({
  query: s.string().min(1),
  category: s.enum(['docs', 'issues', 'pull-requests']),
  limit: s.optional(s.number().min(1).max(20)),
});
 
const SearchResultSchema = s.object({
  results: s.array(
    s.object({
      id: s.string(),
      title: s.string(),
      score: s.number(),
    })
  ),
  total: s.number(),
});
 
type SearchInput = s.infer<typeof SearchInputSchema>;
type SearchResult = s.infer<typeof SearchResultSchema>;
 
async function searchDocuments(input: SearchInput): Promise<SearchResult> {
  const limit = input.limit ?? 10;
 
  return {
    results: [{ id: 'doc_123', title: `Result for ${input.query}`, score: 0.95 }],
    total: limit,
  };
}
 
export async function POST(request: Request): Promise<Response> {
  const input = SearchInputSchema.parse(await request.json());
  const output = await searchDocuments(input);
 
  return Response.json(SearchResultSchema.parse(output));
}

Type inference keeps route code, shared functions, and tests tied to the same source of truth. Change the schema and callers see the new type.

Primitive Types

The s builder provides schemas for all JavaScript primitive types.

SchemaValidatesExample
s.string()string valuess.string().parse('hello')
s.number()number valuess.number().parse(42)
s.boolean()boolean valuess.boolean().parse(true)
s.null()null onlys.null().parse(null)
s.undefined()undefined onlys.undefined().parse(undefined)
s.unknown()any value, typed as unknowns.unknown().parse('anything')
s.any()any value, typed as anys.any().parse(123)

s.string() supports chainable refinements for common validations:

import { s } from '@agentuity/schema';
 
const email = s.string().email();
email.parse('user@example.com'); // 'user@example.com'
 
const username = s.string().min(3).max(20);
username.parse('alice'); // 'alice'
 
const website = s.string().url();
website.parse('https://agentuity.dev'); // 'https://agentuity.dev'

Every schema supports .describe() for documentation. Descriptions carry through to JSON Schema output:

const age = s.number().describe('Age in years');

Complex Types

Build structured schemas with s.object(), s.array(), and s.record().

import { s } from '@agentuity/schema';
 
const UserSchema = s.object({
  name: s.string(),
  age: s.number(),
  tags: s.array(s.string()),
});
 
type User = s.infer<typeof UserSchema>;
// { name: string; age: number; tags: string[] }
 
const user = UserSchema.parse({
  name: 'Alice',
  age: 30,
  tags: ['admin', 'active'],
});

Object schemas support .pick(), .omit(), .partial(), and .extend() for deriving new schemas:

const CreateUser = UserSchema.omit(['age']);
// { name: string; tags: string[] }
 
const UpdateUser = UserSchema.partial();
// { name?: string; age?: number; tags?: string[] }
 
const AdminUser = UserSchema.extend({
  role: s.literal('admin'),
  permissions: s.array(s.string()),
});
// { name: string; age: number; tags: string[]; role: 'admin'; permissions: string[] }

s.record() validates objects with dynamic keys, like TypeScript's Record<string, T>:

const config = s.record(s.string(), s.number());
config.parse({ timeout: 30, retries: 3 }); // OK
config.parse({ timeout: 'fast' }); // throws ValidationError

Utility Types

Utility schemas modify or combine other schemas.

SchemaEffectOutput Type
s.literal(value)Exact value matchvalue (literal type)
s.optional(schema)Allows undefinedT | undefined
s.nullable(schema)Allows nullT | null
s.union(a, b, ...)Matches any of the given schemasA | B | ...
s.enum([...values])Union of literal valuesvalues[number]
import { s } from '@agentuity/schema';
 
// Exact value matching
const admin = s.literal('admin');
admin.parse('admin'); // 'admin'
admin.parse('user');  // throws ValidationError
 
// Optional and nullable fields
const Profile = s.object({
  name: s.string(),
  bio: s.optional(s.string()),     // string | undefined
  avatar: s.nullable(s.string()),  // string | null
});
 
// Union types
const ID = s.union(s.string(), s.number());
ID.parse('abc-123'); // OK
ID.parse(42);        // OK
 
// Enum shorthand: creates a union of literals
const Role = s.enum(['admin', 'editor', 'viewer']);
Role.parse('admin'); // 'admin'
Role.parse('other'); // throws ValidationError
 
// Equivalent to:
// s.union(s.literal('admin'), s.literal('editor'), s.literal('viewer'))

All schemas also have .optional() and .nullable() as chainable methods:

const maybeString = s.string().optional();  // string | undefined
const nullableNum = s.number().nullable();  // number | null

Extracting Types with s.infer

Use s.infer<typeof Schema> to extract the TypeScript type from any schema. Derive the type from the schema instead of writing a separate interface.

import { s } from '@agentuity/schema';
 
const UserSchema = s.object({
  id: s.string(),
  name: s.string(),
  email: s.optional(s.string()),
  role: s.enum(['admin', 'editor', 'viewer']),
});
 
// Extract the type -- equivalent to writing the interface by hand
type User = s.infer<typeof UserSchema>;
// { id: string; name: string; email?: string; role: 'admin' | 'editor' | 'viewer' }
 
// Use the type in function signatures, state, or API boundaries
function greetUser(user: User): string {
  return `Hello, ${user.name} (${user.role})`;
}

s.infer works with all schema types, including nested objects, arrays, unions, and optional/nullable wrappers.

For non-builder schemas, the standalone Infer type export does the same thing:

import { type Infer } from '@agentuity/schema';
 
type User = Infer<typeof UserSchema>;

Type Coercion

Coercion schemas convert input values before validating. Useful for form data, query parameters, and other string-based inputs where the runtime type doesn't match the desired type.

Coercion schemas are accessed via s.coerce.*:

SchemaConversionFails on
s.coerce.string()String(value)Never (all values coerce)
s.coerce.number()Number(value)NaN results
s.coerce.boolean()Boolean(value)Never (uses JS truthiness)
s.coerce.date()new Date(value)Invalid dates
import { s } from '@agentuity/schema';
 
const QueryParams = s.object({
  page: s.coerce.number(),
  limit: s.coerce.number(),
  active: s.coerce.boolean(),
  since: s.coerce.date(),
});
 
// String inputs from query parameters are coerced to the correct types
const params = QueryParams.parse({
  page: '2',           // -> 2
  limit: '25',         // -> 25
  active: 'true',      // -> true
  since: '2025-01-01', // -> Date object
});

s.coerce.number() rejects values that produce NaN, and s.coerce.date() rejects invalid date strings:

s.coerce.number().parse('abc');     // throws: Cannot coerce string to number
s.coerce.date().parse('not-a-date'); // throws: Cannot coerce string to date

JSON Schema Conversion

Convert between @agentuity/schema schemas and a JSON Schema Draft 7 subset. This is useful for LLM structured output, API documentation, and interoperability with tools that consume JSON Schema.

toJSONSchema() converts a schema to a JSON Schema object:

import { s } from '@agentuity/schema';
 
const UserSchema = s.object({
  name: s.string().describe('Full name'),
  age: s.number().describe('Age in years'),
  role: s.enum(['admin', 'user']),
});
 
const jsonSchema = s.toJSONSchema(UserSchema);
// {
//   type: 'object',
//   properties: {
//     name: { type: 'string', description: 'Full name' },
//     age: { type: 'number', description: 'Age in years' },
//     role: { anyOf: [{ type: 'string', const: 'admin' }, { type: 'string', const: 'user' }] }
//   },
//   required: ['name', 'age', 'role']
// }

For LLM structured output (OpenAI, Groq, etc.), pass { strict: true } to add additionalProperties: false to all object schemas:

const strictSchema = s.toJSONSchema(UserSchema, { strict: true });
// Adds additionalProperties: false to the object, required by some LLM providers

fromJSONSchema() converts supported JSON Schema objects back into schemas, including the shapes emitted by toJSONSchema():

const jsonSchema = {
  type: 'object' as const,
  properties: {
    name: { type: 'string' as const },
    score: { type: 'number' as const },
  },
  required: ['name', 'score'],
};
 
const schema = s.fromJSONSchema(jsonSchema);
const result = schema.parse({ name: 'Alice', score: 95 });

Using with AI SDK Structured Output

Use toJSONSchema() with { strict: true } when passing an @agentuity/schema schema to AI SDK structured output. The strict flag adds additionalProperties: false to every object, which providers like OpenAI require.

import { Output, generateText, jsonSchema } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { s } from '@agentuity/schema';
 
const SentimentSchema = s.object({
  sentiment: s.enum(['positive', 'negative', 'neutral']),
  confidence: s.number(),
  reasoning: s.string(),
});
 
type Sentiment = s.infer<typeof SentimentSchema>;
 
const SentimentOutput = jsonSchema<Sentiment>(
  s.toJSONSchema(SentimentSchema, { strict: true }),
  {
    validate: (value) => {
      const result = SentimentSchema.safeParse(value);
 
      return result.success
        ? { success: true, value: result.data }
        : { success: false, error: result.error };
    },
  }
);
 
const { output } = await generateText({
  model: anthropic('claude-opus-4-8'),
  output: Output.object({
    schema: SentimentOutput,
  }),
  prompt: 'Analyze the sentiment of: "I love this product!"',
});
 
const sentiment = output.sentiment;

This approach works with any provider that accepts JSON Schema for structured output.

For supported LLM providers, see AI Gateway.

Validation Errors

Every schema provides two ways to validate data: parse() throws on failure, safeParse() returns a result object.

import { s, ValidationError } from '@agentuity/schema';
import { logger } from '@agentuity/telemetry';
 
const User = s.object({
  name: s.string(),
  age: s.number(),
});
 
// parse() throws a ValidationError on invalid data
try {
  User.parse({ name: 123, age: 'old' });
} catch (err) {
  if (err instanceof ValidationError) {
    logger.info(err.message);
    // [name]: Expected string, got number
    // [age]: Expected number, got string
 
    logger.info('Validation issues', err.issues);
    // [
    //   { message: 'Expected string, got number', path: ['name'] },
    //   { message: 'Expected number, got string', path: ['age'] }
    // ]
  }
}

safeParse() never throws. It returns { success: true, data } on success or { success: false, error } on failure:

const result = User.safeParse({ name: 'Alice', age: 30 });
 
if (result.success) {
  logger.info('Parsed user', result.data); // { name: 'Alice', age: 30 }
} else {
  logger.info('Validation issues', result.error.issues); // ValidationError with issues array
}

ValidationError extends Error, so it works with standard error handling. Each issue includes a message and an optional path array pointing to the field that failed.