ArkType JSON Validation: TypeScript-Native Schemas, Type Inference & Runtime Checks
Last updated:
ArkType validates JSON objects using TypeScript's own type syntax as the schema definition language — type({ name: "string", age: "number > 0" }) defines a runtime validator and infers the TypeScript type { name: string; age: number } simultaneously, with no separate z.infer or InferType call required. The inferred type is available as MySchema.infer (or typeof MySchema.infer), and MySchema(value) validates and returns [value, undefined] on success or [undefined, ArkErrors] on failure — a tuple destructuring pattern that replaces try/catch. ArkErrors has a .summary string property with all issues concatenated for logging, and .toString() for display. At ~2 kB minzipped, ArkType is smaller than Zod (12.9 kB) though its modular footprint varies with usage. This guide covers defining ArkType schemas for JSON objects and arrays, TypeScript type inference with .infer, destructuring the validation result, discriminated unions, template literal types, and integrating ArkType with Next.js API routes.
Defining JSON Schemas with ArkType's TypeScript-Native Syntax
ArkType schemas are defined by passing an object literal to the type() function from the arktype package. Each object value is a string describing the TypeScript type — "string", "number", "boolean", "null", and "undefined" map directly to JSON primitives. Constraints are embedded in the type string using comparison operators: "number > 0" requires a positive number, "string >= 1" requires a non-empty string. Optional fields use a trailing ? on the key name. Union types use the | separator: "string | null". Literal types are wrapped in single quotes inside the string: '"admin"' means the exact string "admin".
import { type } from 'arktype'
// ── Primitive schemas ──────────────────────────────────────────
const StringSchema = type('string')
const NumberSchema = type('number')
const BooleanSchema = type('boolean')
// ── type() — validates a JSON object with named fields ─────────
const UserSchema = type({
id: 'string', // required string
name: 'string >= 1', // non-empty string (length >= 1)
email: 'string.email', // built-in email format keyword
'age?': 'number.integer > 0', // optional positive integer (? on key)
bio: 'string | null', // string or null
createdAt: 'string.iso.datetime', // ISO 8601 datetime string
})
// Inferred TypeScript type — no z.infer needed
type User = typeof UserSchema.infer
// {
// id: string
// name: string
// email: string
// age?: number
// bio: string | null
// createdAt: string
// }
// ── Array schemas ──────────────────────────────────────────────
const UsersSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
}).array()
// Validates User[]
// ── Nested objects ─────────────────────────────────────────────
const OrderSchema = type({
id: 'string',
total: 'number > 0',
items: type({
productId: 'string',
quantity: 'number.integer >= 1',
price: 'number > 0',
}).array(),
user: UserSchema, // nested schema reuse
})
// ── Union types — inline with | ────────────────────────────────
// "string | number" — accepts strings or numbers
const IdSchema = type('string | number')
// ── Literal types — single-quoted inside the string ────────────
const RoleSchema = type('"admin" | "user" | "guest"')
// Inferred type: "admin" | "user" | "guest"
// ── Validating ────────────────────────────────────────────────
const json = {
id: 'abc123',
name: 'Alice',
email: 'alice@example.com',
bio: null,
createdAt: '2026-05-28T10:00:00Z',
}
const [user, errors] = UserSchema(json) // tuple destructuring
if (errors) {
console.error(errors.summary) // all issues as one string
} else {
console.log(user.email) // typed as string
}
// ── type.assert() — throws ArkError on failure ─────────────────
// Use for startup validation where failure is a programming error
const validUser = UserSchema.assert(json) // throws if invalidArkType v2.0 uses a module called type (lowercase) — import it as a named export: import { type } from 'arktype'. The schema object syntax is the primary API; ArkType also exposes a string-only API for simple types. String keywords like string.email, string.url, string.uuid, and string.iso.datetime are built-in format validators that do not require additional plugins or packages, unlike Zod which requires z.string().email() chaining or Ajv which requires ajv-formats.
Validating and Destructuring Results: The [value, errors] Pattern
Every ArkType schema is callable as a function. Calling MySchema(input) returns a 2-element tuple: [data, errors]. On success, data is the typed value and errors is undefined. On failure, data is undefined and errors is an ArkErrors instance. This tuple pattern enables inline control flow without try/catch. ArkErrors.summary provides a single human-readable string of all issues — useful for logging or a quick API error message. For structured error details, iterate over errors as an array of individual ArkError objects.
import { type } from 'arktype'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const CreateUserSchema = type({
name: 'string >= 1',
email: 'string.email',
age: 'number.integer > 17', // must be 18+ (> 17)
})
// ── Basic tuple destructuring ──────────────────────────────────
const raw = { name: '', email: 'not-an-email', age: 15 }
const [user, errors] = CreateUserSchema(raw)
if (errors) {
// errors.summary — all issues joined as a single string
// e.g. "name must be a string of length >= 1, email must be a valid email address, age must be a number > 17"
console.error(errors.summary)
// Iterate individual issues
for (const issue of errors) {
console.error(issue.message, 'at path:', issue.path)
}
}
// ── API Route Handler — Next.js App Router ─────────────────────
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const [data, errors] = CreateUserSchema(body)
if (errors) {
// Return 422 with a human-readable summary
return NextResponse.json(
{ success: false, message: errors.summary },
{ status: 422 }
)
}
// data is fully typed as { name: string; email: string; age: number }
const user = await db.createUser(data)
return NextResponse.json({ success: true, user }, { status: 201 })
}
// ── type.assert() — throws ArkError for startup validation ─────
// Good for: env vars, startup config, internal invariants
const EnvSchema = type({
DATABASE_URL: 'string.url',
PORT: 'number.integer >= 1024 & <= 65535',
NODE_ENV: '"development" | "production" | "test"',
})
// Throws ArkError (singular) if invalid — fail-fast at startup
const env = EnvSchema.assert(process.env)
// env.DATABASE_URL is typed as string — no ! assertion needed
// ── Result shape reference ─────────────────────────────────────
// [data, undefined] — success: data is the typed value
// [undefined, ArkErrors] — failure: errors.summary has all issues
// ArkErrors.summary — string, all issues concatenated
// ArkErrors[n].message — string, individual issue message
// ArkErrors[n].path — (string | number)[], field pathThe tuple destructuring pattern is idiomatic ArkType — unlike Zod's { success, data, error } object, destructuring with const [data, errors] = Schema(input) is concise and mirrors patterns from Go and Rust where multiple return values indicate success or failure. TypeScript narrows the tuple correctly: after the if (errors) check, data is narrowed to the fully-typed schema type within the else branch, and errors is narrowed to ArkErrors within the if block.
TypeScript Type Inference with .infer
ArkType extracts the inferred TypeScript type through the .infer property on the compiled schema — no separate z.infer<typeof Schema> call is needed. MySchema.infer is not a runtime value; it is a TypeScript type accessed via typeof MySchema.infer. This single property replaces maintaining parallel interface declarations alongside validation logic. When the schema changes, the inferred type changes automatically.
import { type } from 'arktype'
// ── Define schema once ─────────────────────────────────────────
const ProductSchema = type({
id: 'string',
name: 'string',
price: 'number > 0',
tags: 'string[]',
'metadata?': 'Record<string, unknown>',
})
// ── .infer — TypeScript type extracted at compile time ─────────
// typeof ProductSchema.infer gives the inferred output type
type Product = typeof ProductSchema.infer
// {
// id: string
// name: string
// price: number
// tags: string[]
// metadata?: Record<string, unknown>
// }
// ── Use inferred types in function signatures ──────────────────
// In a shared /types/api.ts file:
export const ApiUserSchema = type({
id: 'string',
name: 'string',
email: 'string.email',
})
export type ApiUser = typeof ApiUserSchema.infer
// Frontend — validates fetch response
async function fetchUser(id: string): Promise<ApiUser> {
const res = await fetch(`/api/users/${id}`)
const json = await res.json()
const [user, errors] = ApiUserSchema(json)
if (errors) throw new Error(`API response invalid: ${errors.summary}`)
return user // typed as ApiUser
}
// Backend — uses same type in handler
async function storeUser(data: ApiUser): Promise<void> {
await db.insert('users', data) // data fully typed as ApiUser
}
// ── Array schema inference ─────────────────────────────────────
const UsersSchema = ApiUserSchema.array()
type Users = typeof UsersSchema.infer // ApiUser[]
// ── Schema composition with .and() and .or() ──────────────────
const TimestampSchema = type({
createdAt: 'string.iso.datetime',
updatedAt: 'string.iso.datetime',
})
// Intersection: both schemas must pass
const TimestampedUserSchema = ApiUserSchema.and(TimestampSchema)
type TimestampedUser = typeof TimestampedUserSchema.infer
// { id: string; name: string; email: string; createdAt: string; updatedAt: string }
// ── Infer is a TypeScript feature — zero runtime cost ─────────
// typeof Schema.infer is erased at compile time
// No code generation, no .d.ts files, no build step requiredA practical pattern for full-stack TypeScript monorepos: define all API schemas in a shared package, export both the schema and typeof Schema.infer as a type alias, and import them in both the frontend (for validation) and the backend (for handler types). The schema is the single source of truth — TypeScript types and runtime validators are derived from it. The .and() method creates schema intersections (both must pass), and .or() creates unions (either must pass), enabling composition without redefining base schemas.
Optional Fields, Union Types, and Nullable Values
ArkType expresses optional, nullable, and union types directly in TypeScript-native syntax — no method chains required. Optional fields append ? to the key name in the schema object. Nullable values use a | null union in the type string. For fields that are both optional and nullable, combine both. Union types use | inline with as many alternatives as needed. Enum-style literals use single-quoted strings inside the type string.
import { type } from 'arktype'
// ── Optional fields — ? on the key name ───────────────────────
const ProfileSchema = type({
id: 'string',
name: 'string',
'bio?': 'string', // optional: may be absent
'avatarUrl?': 'string.url', // optional URL
'age?': 'number.integer >= 0 & <= 150', // optional age range
})
type Profile = typeof ProfileSchema.infer
// { id: string; name: string; bio?: string; avatarUrl?: string; age?: number }
// ── Nullable fields — | null in the type string ───────────────
const PostSchema = type({
id: 'string',
title: 'string',
body: 'string',
deletedAt: 'string.iso.datetime | null', // nullable timestamp
publishedAt: 'string.iso.datetime | null', // present or null
})
// ── Optional AND nullable — both modifiers together ───────────
const UserProfileSchema = type({
id: 'string',
'displayName?': 'string | null', // optional field that can be null when present
'phone?': 'string | null',
})
// ── Union types with multiple alternatives ─────────────────────
const StatusSchema = type('"active" | "inactive" | "pending" | "deleted"')
type Status = typeof StatusSchema.infer // "active" | "inactive" | "pending" | "deleted"
const IdSchema = type('string | number') // string or number ID
const MixedArraySchema = type('(string | number | boolean)[]')
// ── Enum-like literal unions ───────────────────────────────────
const ThemeSchema = type({
mode: '"light" | "dark" | "system"',
accent: '"blue" | "green" | "purple" | "red"',
size: '"sm" | "md" | "lg"',
})
type Theme = typeof ThemeSchema.infer
// { mode: "light" | "dark" | "system"; accent: ...; size: ... }
// ── Validating optional and nullable inputs ────────────────────
const [profile, err1] = ProfileSchema({ id: '1', name: 'Alice' })
// OK — bio, avatarUrl, age all absent (optional)
const [post, err2] = PostSchema({
id: '1', title: 'Hi', body: 'Hello world',
deletedAt: null, publishedAt: null,
})
// OK — null is accepted for nullable fields
const [_, err3] = PostSchema({
id: '1', title: 'Hi', body: 'Hello',
deletedAt: 'not-a-date', publishedAt: null,
})
// Fails — deletedAt must be ISO datetime or nullThe & operator in ArkType type strings creates intersection constraints — "number.integer >= 0 & <= 150" means an integer between 0 and 150 inclusive. This is distinct from schema intersection via .and(), which combines two entire schemas. Inline & constraints work on primitives; .and() combines full object schemas. Both produce the correct TypeScript intersection type in the inferred output.
Template Literals and Advanced String Validation
ArkType supports TypeScript template literal types natively in the type string syntax. A template literal like '${string}@${string}.${string}' matches any string containing an @ and a . — approximating email format at the type level. Template literals combine with prefix/suffix patterns, built-in string keywords, and constraint operators to express complex string shapes concisely.
import { type } from 'arktype'
// ── Built-in string format keywords ───────────────────────────
const FormatsSchema = type({
email: 'string.email',
url: 'string.url',
uuid: 'string.uuid',
datetime: 'string.iso.datetime',
ipv4: 'string.ip.v4',
ipv6: 'string.ip.v6',
semver: 'string.semver',
creditCard: 'string.creditCard',
alphanumeric: 'string.alpha', // letters only
numeric: 'string.numeric', // digits only
})
// ── Template literal types ─────────────────────────────────────
// Validates strings matching the TypeScript template literal pattern
// '${string}@${string}.${string}' — email-like pattern at type level
const EmailLikeSchema = type('`${string}@${string}.${string}`')
// '${string}-${string}' — dash-separated slug pattern
const SlugSchema = type('`${string}-${string}`')
// Prefix patterns — strings starting with a specific prefix
const ApiKeySchema = type('`sk_${string}`') // Stripe-like API key
const UuidV4Schema = type('`${string}-${string}-4${string}`') // UUID v4 rough match
// ── String constraint operators ────────────────────────────────
// >= and <= on strings compare string LENGTH
const UsernameSchema = type({
username: 'string >= 3 & <= 20 & /^[a-z0-9_]+$/', // length + pattern
password: 'string >= 8', // min length
slug: 'string >= 1 & /^[a-z0-9-]+$/', // non-empty + pattern
})
// ── Regex patterns inline ──────────────────────────────────────
// Use /regex/ directly in the type string
const PostalCodeSchema = type('/^[0-9]{5}(-[0-9]{4})?$/')
const HexColorSchema = type('/^#[0-9a-fA-F]{6}$/')
const PhoneSchema = type('/^\+?[1-9]\d{1,14}$/') // E.164 format
// ── Combining string keywords with constraints ─────────────────
const ShortEmailSchema = type('string.email & string <= 100') // email, max 100 chars
// ── Example: full API key schema with template literals ────────
const ApiRequestSchema = type({
authorization: '`Bearer ${string}`', // must start with "Bearer "
contentType: '"application/json"', // must be this exact string
requestId: 'string.uuid', // UUID v4 format
})
const [req, errors] = ApiRequestSchema({
authorization: 'Bearer sk_live_abc123',
contentType: 'application/json',
requestId: '550e8400-e29b-41d4-a716-446655440000',
})
// OK — all fields match their patternsArkType's template literal support is a first-class feature enabled by its TypeScript-native syntax — the same template literal syntax used in TypeScript type definitions works directly in ArkType's string schemas. This enables type-level string pattern constraints that are checked at compile time (TypeScript) and at runtime (ArkType) simultaneously, without any code generation or additional tooling. The inferred TypeScript type for a template literal schema is the corresponding TypeScript template literal type, preserving full type safety downstream.
Discriminated Unions for Polymorphic JSON
ArkType handles discriminated unions through its .or() method combined with literal type fields. When all union branches share a common field with a distinct literal type, ArkType automatically detects the discriminant and performs an optimized O(1) branch selection — equivalent to Zod's z.discriminatedUnion() but without requiring a separate API call. The inferred TypeScript type is the correct discriminated union.
import { type } from 'arktype'
// ── Discriminated union — type field selects the branch ────────
const ClickEventSchema = type({
type: '"click"',
x: 'number',
y: 'number',
button: '"left" | "right" | "middle"',
})
const KeypressEventSchema = type({
type: '"keypress"',
key: 'string',
code: 'string',
ctrlKey: 'boolean',
})
const ResizeEventSchema = type({
type: '"resize"',
width: 'number > 0',
height: 'number > 0',
})
// Union the three schemas — ArkType auto-detects "type" as discriminant
const EventSchema = ClickEventSchema.or(KeypressEventSchema).or(ResizeEventSchema)
type Event = typeof EventSchema.infer
// { type: "click"; x: number; y: number; button: "left"|"right"|"middle" }
// | { type: "keypress"; key: string; code: string; ctrlKey: boolean }
// | { type: "resize"; width: number; height: number }
// ── Validating and narrowing ───────────────────────────────────
function handleEvent(raw: unknown) {
const [event, errors] = EventSchema(raw)
if (errors) {
console.error('Invalid event:', errors.summary)
return
}
// TypeScript narrows based on event.type
if (event.type === 'click') {
console.log(`Clicked at ${event.x}, ${event.y} with ${event.button}`)
} else if (event.type === 'keypress') {
console.log(`Key pressed: ${event.key} (ctrl: ${event.ctrlKey})`)
} else {
console.log(`Resized to ${event.width}×${event.height}`)
}
}
// ── Tagged union for API responses ────────────────────────────
const SuccessResponseSchema = type({
status: '"success"',
data: 'Record<string, unknown>',
message: 'string',
})
const ErrorResponseSchema = type({
status: '"error"',
code: 'number',
message: 'string',
details: 'string[]',
})
const ApiResponseSchema = SuccessResponseSchema.or(ErrorResponseSchema)
type ApiResponse = typeof ApiResponseSchema.infer
async function callApi(endpoint: string): Promise<ApiResponse> {
const res = await fetch(endpoint)
const json = await res.json()
const [data, errors] = ApiResponseSchema(json)
if (errors) throw new Error(`Unexpected API shape: ${errors.summary}`)
return data
}
// ── Accessing response discriminated by status ─────────────────
const response = await callApi('/api/users')
if (response.status === 'success') {
console.log(response.data) // typed as Record<string, unknown>
} else {
console.error(response.code, response.details)
}ArkType's automatic discriminant detection works when every branch in a union has a field with a distinct literal type value — "click", "keypress", "resize" on the typefield in the example above. When ArkType detects this pattern, it reads the discriminant field value from the input and jumps directly to the matching branch, skipping validation of the other branches. This produces clearer error messages (only the matching branch's errors are reported) and better runtime performance for large union types.
Integrating ArkType with Next.js API Routes
ArkType works in Next.js App Router Route Handlers, Server Actions, and Middleware without any configuration. Its ~2 kB bundle size makes it suitable for Edge Runtime (Vercel Edge, Cloudflare Workers) where Zod's 12.9 kB has a measurable cold-start impact. Define schemas in a shared file, import them in route handlers, and destructure the [data, errors] tuple to return typed responses or 422 errors.
// lib/schemas.ts — shared schema definitions
import { type } from 'arktype'
export const CreateUserSchema = type({
name: 'string >= 1 & <= 100',
email: 'string.email',
age: 'number.integer > 0',
'role?': '"admin" | "user" | "viewer"',
})
export const UpdateUserSchema = type({
'name?': 'string >= 1 & <= 100',
'email?': 'string.email',
'role?': '"admin" | "user" | "viewer"',
})
export type CreateUserInput = typeof CreateUserSchema.infer
export type UpdateUserInput = typeof UpdateUserSchema.infer
// app/api/users/route.ts — POST handler
import { type NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { CreateUserSchema } from '@/lib/schemas'
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ success: false, message: 'Request body must be valid JSON' },
{ status: 400 }
)
}
const [data, errors] = CreateUserSchema(body)
if (errors) {
return NextResponse.json(
{ success: false, message: errors.summary },
{ status: 422 }
)
}
// data is typed as CreateUserInput
const user = await db.users.create({ data })
return NextResponse.json({ success: true, user }, { status: 201 })
}
// app/api/users/[id]/route.ts — PATCH handler
import { UpdateUserSchema } from '@/lib/schemas'
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
let body: unknown
try { body = await request.json() } catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
const [data, errors] = UpdateUserSchema(body)
if (errors) {
return NextResponse.json({ error: errors.summary }, { status: 422 })
}
const updated = await db.users.update({ where: { id: params.id }, data })
return NextResponse.json({ user: updated })
}
// app/actions/create-user.ts — Next.js Server Action
'use server'
import { CreateUserSchema } from '@/lib/schemas'
export async function createUserAction(formData: FormData) {
const raw = Object.fromEntries(formData)
const [data, errors] = CreateUserSchema(raw)
if (errors) {
return { success: false as const, message: errors.summary }
}
const user = await db.users.create({ data })
return { success: true as const, user }
}
// Edge Runtime middleware — validate API key header
// middleware.ts
import { type } from 'arktype'
const AuthHeaderSchema = type({ authorization: '`Bearer ${string}`' })
export function middleware(request: NextRequest) {
const [headers, errors] = AuthHeaderSchema({
authorization: request.headers.get('authorization') ?? '',
})
if (errors) {
return NextResponse.json(
{ error: 'Missing or invalid Authorization header' },
{ status: 401 }
)
}
return NextResponse.next()
}
export const config = { matcher: '/api/:path*' }For Server Actions, note that FormData values are always strings — ArkType does not coerce types by default, unlike Zod's z.coerce.number(). For numeric form fields, either use Number(formData.get('age')) before validation, or define a custom morph (ArkType's term for a transform) that converts the string. ArkType's morph API uses type(sourceSchema).pipe(transformFn, targetSchema) to express input-to-output transformations with full type tracking.
Key Terms
- type()
- The primary ArkType function for defining schemas. Called with an object literal (for object schemas) or a type string (for primitive schemas),
type()compiles the schema definition into a callable validator. The compiled schema is both a function —Schema(input)returns a[data, errors]tuple — and an object with properties including.infer(TypeScript type),.assert(input)(throws on failure), and.array()(creates an array schema). ArkType v2.0 (2024) introduced the string-based syntax; earlier v1.x used a different object-based API. Always check which version is installed before reading older documentation or Stack Overflow answers. - ArkErrors
- The error container returned as the second element of the validation tuple when a schema fails.
ArkErrorsis an array-like object — iterating over it yields individualArkErrorentries, each with a.messagestring and a.patharray indicating the field path. The most useful property is.summary, which joins all issue messages into a single human-readable string suitable for logging or a quick API error response body.ArkErrors.toString()is equivalent to.summary. For structured error responses (field-level error maps), iterate over the errors and build a map fromerror.path.join('.')toerror.message. - morph
- ArkType's term for a transform — a function that converts the validated value to a different type after validation passes. Morphs are defined using the
.pipe(transform, outputSchema)method on a schema, wheretransformis a function andoutputSchemadescribes the output type. This is equivalent to Zod's.transform(). A schema with a morph has separate input and output types: the input type is whatSchema()accepts, and the output type is what the first element of the tuple contains on success. Usetypeof Schema.inferInfor the input type andtypeof Schema.inferfor the output type. Morphs are useful for coercing string form data to numbers, converting snake_case API responses to camelCase, and parsing date strings toDateobjects. - type string
- ArkType's schema definition language is embedded in JavaScript string literals that mirror TypeScript type syntax. A type string like
"string >= 1 & <= 100"means a string with length between 1 and 100. Built-in keywords include primitive types (string,number,boolean,null,undefined), format validators (string.email,string.uuid,string.url), and numeric qualifiers (number.integer,number.positive). Literal types use single quotes inside the string:'"admin"'. Union types use|. Intersection constraints use&. Template literals use backtick syntax. ArkType parses these strings at startup and reports syntax errors immediately — type string errors are caught before any validation runs. - discriminated union
- A union schema where ArkType automatically detects a discriminant field — a field present in all branches with a unique literal value per branch. When ArkType identifies a discriminant, it reads that field's value from the input and jumps directly to the matching branch, skipping the others. This is O(1) lookup versus O(n) sequential testing of all branches. The result is faster validation for large unions and clearer error messages (only the selected branch's errors are reported). ArkType detects discriminants automatically when using
.or()to combine schemas — no separate API call is needed, unlike Zod'sz.discriminatedUnion(discriminantKey, [...]).
FAQ
How is ArkType different from Zod for JSON validation?
ArkType and Zod both provide runtime JSON validation with TypeScript type inference, but differ fundamentally in syntax and design philosophy. Zod uses a fluent builder API — z.object({ name: z.string(), age: z.number() }) — while ArkType uses TypeScript's own type syntax as string literals: type({ name: "string", age: "number" }). ArkType schemas read like TypeScript type annotations rather than function chains. ArkType also differs in its result model: validation returns a [value, errors] tuple rather than throwing or returning a { success, data, error } object. For bundle size, ArkType weighs approximately 2 kB minzipped for a basic schema, compared to Zod at 12.9 kB — a meaningful difference for client-side bundles. ArkType v2.0 (released 2024) introduced the string-based syntax; it was a breaking change from v1.x. ArkType also supports template literal types natively using TypeScript syntax, enabling email-like pattern validation at the type level without custom refinements.
How do I define a JSON object schema in ArkType?
Import the type function from the arktype package and pass an object literal where each value is a string describing the TypeScript type: import { type } from "arktype"; const UserSchema = type({ id: "string", name: "string", age: "number > 0" }). String values mirror TypeScript type syntax — "string", "number", "boolean", "null" for primitives. Constraints are written inline: "number > 0" requires a positive number, "string >= 5" requires a string longer than 5 characters. For optional fields, append a question mark to the key: type({ name: "string", bio: "string?" }) using the ? suffix on the key name. For nullable fields, use a union with null: type({ avatar: "string | null" }). ArkType v2.0 introduced this string-based syntax — it represents a complete redesign from the v1.x API, so check the version you have installed before reading older documentation.
How does the ArkType validation result tuple work?
Calling a compiled ArkType schema as a function — MySchema(value) — returns a 2-element tuple: [data, errors]. On success, the tuple is [typedValue, undefined], where typedValue is the input narrowed to the inferred TypeScript type. On failure, the tuple is [undefined, ArkErrors], where ArkErrors is an object containing all validation issues. Destructuring follows JavaScript array destructuring: const [user, errors] = UserSchema(rawInput). Check for errors first — if (errors) {'{ console.error(errors.summary) }'} — then use user as the typed value. ArkErrors has a .summary property that returns all issues as a single human-readable string, useful for logging. ArkErrors also iterates as an array of individual ArkError entries. ArkType also provides type.assert() for cases where you want a throw on failure — it throws ArkError (singular) rather than returning errors.
How do I handle optional and nullable fields in ArkType?
ArkType handles optional and nullable fields using TypeScript-native syntax. For optional fields (may be absent), append ? to the key name in the schema object: type({ name: "string", "age?": "number" }). This makes age optional — the inferred TypeScript type is { name: string; age?: number }. For nullable fields (may be null), use a union type string: type({ avatar: "string | null" }). The inferred type becomes { avatar: string | null }. For fields that are both optional and nullable, combine both: type({ "bio?": "string | null" }). ArkType's string union syntax "string | null" directly mirrors TypeScript union types, making the intent clear. Unlike Zod's .optional() and .nullable() method chains, ArkType expresses these constraints in the type string itself. For a full partial schema, define each field with ?or use ArkType's schema composition API.
How do I validate JSON arrays with ArkType?
ArkType validates JSON arrays using TypeScript array type syntax. To validate an array of strings, write type("string[]"). To validate an array of objects, define the element schema and call .array() on it: const ItemSchema = type({'{ id: "string", qty: "number > 0" }'}); const ItemsSchema = ItemSchema.array(). For inline array types, use the bracket notation directly: type("string[]") or type("number[]"). For mixed-type arrays, use union syntax: type("(string | number)[]"). For fixed-length tuples, ArkType supports TypeScript tuple notation passed as an array to type(): type(["string", "number", "boolean"]) validates a 3-element tuple. Validate as always: const [items, errors] = ItemsSchema(rawJson). ArkErrors.summary provides a single string with all element-level issues, for example "items[2].qty must be a positive number".
How do I define discriminated unions in ArkType?
ArkType supports discriminated unions through its .or() method combined with literal type fields. Define each variant as a separate schema and chain .or(): const EventSchema = type({'{ kind: \'\"click\"\', x: "number" }'}).or(type({'{ kind: \'\"keypress\"\', key: "string" }'})).or(type({'{ kind: \'\"scroll\"\', delta: "number" }'})). ArkType uses single-quoted string literals inside the type string to represent literal types: '"click"' means the string "click" exactly. ArkType automatically detects the discriminant field when all union branches share a literal-typed field, performing a fast O(1) lookup instead of trying each branch in sequence — this matches Zod's discriminatedUnion() behavior but without requiring a separate API call. The inferred TypeScript type is the correct discriminated union type.
How does ArkType compare to Zod and Valibot in bundle size?
Bundle size varies by library and usage. Zod 3.x is 12.9 kB minzipped as a complete bundle — you import the entire library. Valibot uses a modular per-import design where each validator is a separate export, so bundle size scales with usage — a minimal schema costs approximately 1 kB per import, but a complex schema with many validators can reach 5–8 kB total. ArkType weighs approximately 2 kB minzipped for a basic schema including the runtime. These numbers reflect real bundle analyzer output for representative schemas; your actual bundle size depends on tree-shaking, bundler configuration, and how many schema features you use. For client-side applications where bundle size matters — mobile web, low-bandwidth users, Edge Runtime cold starts — Valibot and ArkType are preferable to Zod. For server-side Node.js applications where bundle size is irrelevant, Zod's larger ecosystem (React Hook Form resolver, tRPC integration, zod-openapi) is often worth the overhead. ArkType v2.0 (2024) was specifically designed with performance and small footprint as primary goals.
How do I integrate ArkType with a Next.js API route?
In a Next.js App Router Route Handler, import your ArkType schema and call it with the parsed request body. Define schemas in a shared file: import { type } from "arktype"; export const CreateUserSchema = type({ name: "string >= 1", email: "string.email", age: "number.integer > 0" }). In the route handler: const body = await request.json(); const [data, errors] = CreateUserSchema(body); if (errors) {'{ return NextResponse.json({ success: false, message: errors.summary }, { status: 422 }); }'}. Use typeof CreateUserSchema.infer as a type alias for the validated data type. ArkType works identically in Edge Runtime since it has no Node.js-specific dependencies. The ~2 kB bundle size adds minimal overhead to Edge Function cold starts, where bundle size directly impacts latency. For Server Actions with FormData, note that values are always strings — use ArkType's morph API (.pipe()) to coerce string inputs to numbers or other types before validation.
Validate JSON with TypeScript-native schemas
Use Jsonic's JSON validator to check your JSON structure before writing ArkType schemas — catch syntax errors instantly before validation logic runs.
Open JSON ValidatorFurther reading and primary sources
- ArkType Documentation — Official ArkType docs covering type strings, morphs, scopes, and advanced patterns
- ArkType GitHub Repository — Source code, changelog, migration guides, and community issues for ArkType
- ArkType v2.0 Migration Guide — Breaking changes from v1.x to v2.0: string syntax, result tuple, and scope API
- Zod Documentation — Official Zod docs — for comparison with ArkType's approach to JSON validation
- Valibot Documentation — Valibot modular validation library — per-import bundle size as an alternative to ArkType