Why ArkType?
Every runtime validator asks you to learn a second language. In Zod you write z.object({ name: z.string() }). In Valibot you write v.object({ name: v.string() }). Both work — but neither looks like the TypeScript you already write every day.
ArkType takes the opposite bet. You write the type:
const User = type({ name: "string" })That string "string" is not a magic keyword. It is a parsed TypeScript type expression, checked by the TypeScript compiler itself at edit time. Type a typo like "strng" and your editor underlines it before you ever run the code. Write "string | number" and you get a discriminated union at runtime and a string | number inference statically — from the same six characters.
The design goal is isomorphism: one definition, correct in both worlds. The payoff is that your schemas stop being a parallel universe you have to keep in sync with your types.
This tutorial builds a small but complete Next.js 15 application using ArkType 2.2 — the release that added validated functions, typed regex capture groups, N-ary operators, and Standard Schema interop.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed
- TypeScript 5.1 or newer (5.9+ recommended; ArkType leans hard on modern inference)
- Familiarity with Next.js App Router and Server Actions
- A code editor with a working TypeScript language server (VS Code, WebStorm, Zed)
- Basic understanding of runtime validation — if you have used Zod or Valibot, you are more than ready
What You'll Build
A "conference talk submission" feature with validation at every boundary:
- A typed schema for talk submissions, written in TypeScript syntax
- A morph pipeline that turns raw
FormDatastrings into real numbers, dates, and slugs - Typed regex extraction of structured data from a session code
- A reusable scope shared between client and server
- A validated Server Action that returns field-level errors
- Runtime-checked environment variables that fail at boot, not in production
- A JSON Schema export for your public API docs
Everything type-checks end to end. No manual interfaces, no duplicated shapes.
Step 1: Project Setup
Create a fresh Next.js project and install ArkType:
npx create-next-app@latest talk-portal --typescript --app --tailwind --eslint
cd talk-portal
npm install arktypeArkType is a single dependency with zero runtime packages of its own. Add the optional JSON Schema bridge now — you will use it in Step 12:
npm install @ark/json-schemaOpen tsconfig.json and confirm strict mode is on. ArkType's inference depends on it:
{
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"moduleResolution": "bundler"
}
}If strict is off, ArkType still validates correctly at runtime but its static inference degrades badly — optional keys and morph outputs will widen to any in places. Turn it on before you write a single schema.
Step 2: Your First Type
Create lib/schemas/talk.ts:
import { type } from "arktype"
export const Talk = type({
title: "string",
track: "'ai' | 'web' | 'devops' | 'design'",
"coAuthor?": "string"
})Three things happened in six lines:
"string"parsed into a runtime string validator."'ai' | 'web' | 'devops' | 'design'"parsed into a discriminated union — ArkType compiles this to a switch, not a chain of comparisons."coAuthor?"marked the key optional. The?lives on the key, not the value, exactly as it does in a TypeScript interface.
Compare that to the equivalent interface you would have written anyway:
interface Talk {
title: string
track: "ai" | "web" | "devops" | "design"
coAuthor?: string
}The shapes are nearly identical. That is the entire point.
Step 3: Validating and Handling Errors
ArkType types are callable. Call one with unknown data and you get back either the validated value or an error object:
import { type } from "arktype"
import { Talk } from "@/lib/schemas/talk"
const out = Talk({
title: "Shipping AI Agents",
track: "quantum"
})
if (out instanceof type.errors) {
console.error(out.summary)
// track must be "ai", "web", "devops" or "design" (was "quantum")
} else {
console.log(out.title) // fully typed as string
}There is no .parse() versus .safeParse() split. One call site, one branch. The instanceof type.errors check is a TypeScript type guard, so inside the else branch out narrows to the validated type automatically.
When you want the throwing behaviour — config loading, tests, trusted internal calls — use .assert():
const talk = Talk.assert({ title: "Shipping AI Agents", track: "ai" })
// throws a TraversalError if invalid, returns the typed value otherwiseFor field-level UI errors, iterate the error array instead of reading the summary:
if (out instanceof type.errors) {
for (const error of out) {
console.log(error.path, error.message)
// ["track"] must be "ai", "web", "devops" or "design" (was "quantum")
}
}out.summary is a human-readable multi-line string, ideal for logs and CLI output. The iterable form with error.path is what you want for forms, because it tells you which field to highlight.
Step 4: Inferring TypeScript Types
Never hand-write the interface. Derive it:
export type Talk = typeof Talk.inferYou now have a value called Talk and a type called Talk in the same module — TypeScript keeps value and type namespaces separate, so this is legal and idiomatic in ArkType codebases.
When a schema has morphs (Step 6), the input and output types diverge. ArkType exposes both:
type TalkInput = typeof Talk.inferIn // what you pass in
type TalkOutput = typeof Talk.infer // what you get backUse inferIn for form state and API request types; use infer for everything downstream of validation.
Step 5: Constraints and Built-in Keywords
Raw "string" is rarely enough. ArkType ships a keyword library and a constraint syntax that both live inside the definition string.
Expand lib/schemas/talk.ts:
import { type } from "arktype"
export const Talk = type({
// length constraints read like comparisons
title: "5 <= string <= 120",
abstract: "string >= 200",
// dotted keywords for common formats
email: "string.email",
slidesUrl: "string.url",
submissionId: "string.uuid",
// numeric constraints and divisibility
durationMinutes: "number.integer >= 15",
seatBlock: "number % 5",
track: "'ai' | 'web' | 'devops' | 'design'",
// arrays are a suffix, exactly like TypeScript
tags: "string[]",
// and they take their own length bounds
speakers: "string.email[] >= 1"
})A tour of what is happening:
| Syntax | Meaning |
|---|---|
5 <= string <= 120 | String length between 5 and 120 inclusive |
string >= 200 | Minimum length 200 |
number.integer >= 15 | Whole number, at least 15 |
number % 5 | Divisible by 5 |
string[] | Array of strings |
string.email[] >= 1 | Non-empty array of email strings |
ArkType 2.2 also added string.hex and string.regex to the keyword set, alongside existing ones like string.date, string.json, string.semver, string.ip, and number.epoch.
Intersections use &, so you can stack a keyword with a pattern:
const CompanyEmail = type("string.email & /@noqta\\.tn$/")Inside a TypeScript string literal, every backslash in a regex must be doubled. /\d{2}/ becomes "/\\d{2}/". Forget this and you will get a confusing parse error pointing at the wrong character.
Step 6: Morphs — Parse, Don't Just Validate
Web input arrives as strings. A validator that only says "this is a valid number string" leaves you calling Number() by hand afterwards, outside the type system. Morphs close that gap.
A morph is a function applied after validation, and its return type flows into the inferred output type. Create lib/schemas/form.ts:
import { type } from "arktype"
// tuple form: [input, "=>", transform]
const TrimmedString = type("string", "=>", (s) => s.trim())
// the pipe operator chains validate -> transform -> validate
const PositiveIntFromString = type("string.integer.parse |> number > 0")
const Slug = type("string", "=>", (s) =>
s
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
)
export const TalkFormInput = type({
title: TrimmedString,
durationMinutes: PositiveIntFromString,
slug: Slug
})Run it:
const out = TalkFormInput({
title: " Shipping AI Agents ",
durationMinutes: "45",
slug: "Shipping AI Agents!"
})
// out.title -> "Shipping AI Agents" (string)
// out.durationMinutes -> 45 (number, not string)
// out.slug -> "shipping-ai-agents" (string)Note the inferred types. durationMinutes is typed number on the way out and string on the way in — typeof TalkFormInput.inferIn and typeof TalkFormInput.infer differ, and both are correct.
The |> pipe operator became embeddable in definition strings in 2.2, and type.pipe() gives you the N-ary function form when chaining gets long:
const TrimToNonEmpty = type.pipe(
type.string,
(s) => s.trimStart(),
type.string.atLeastLength(1)
)Morphs can also fail. Return ctx.error(...) to reject inside a transform:
const IsoDate = type("string", "=>", (s, ctx) => {
const date = new Date(s)
return Number.isNaN(date.valueOf())
? ctx.error("a valid ISO 8601 date")
: date
})Now IsoDate infers as Date and rejects garbage with a proper ArkType error rather than throwing.
Morphs are the reason "parse, don't validate" is easy in ArkType. Push every string-to-domain-object conversion into the schema, and the rest of your codebase never sees an unparsed value.
Step 7: Type-Safe Regex with Capture Groups
This is the marquee feature of ArkType 2.2, and it has no equivalent in the other major validators.
Prefix a regex literal with x/ and ArkType parses the pattern at the type level, giving you typed named capture groups on the validated output:
import { type } from "arktype"
export const SessionCode = type({
code: "x/^(?<year>\\d{4})-(?<track>[A-Z]{3})-(?<seq>\\d{3})$/"
})
const data = SessionCode.assert({ code: "2026-WEB-014" })
data.code.groups.year // "2026" — typed as string, autocompleted
data.code.groups.track // "WEB"
data.code.groups.seq // "014"Hover groups in your editor: TypeScript knows the exact key names, because the compiler parsed the pattern. Rename a capture group in the regex and every consumer breaks at compile time. Misspell data.code.groups.yaer and you get an error, not undefined at 3 a.m.
The underlying machinery ships as ArkRegex, a drop-in RegExp replacement with full type inference and no runtime overhead — the parsing is entirely in the type system.
Combine it with a morph to extract structured data in one pass:
export const ParsedSessionCode = type(
"x/^(?<year>\\d{4})-(?<track>[A-Z]{3})-(?<seq>\\d{3})$/",
"=>",
(code) => ({
year: Number(code.groups.year),
track: code.groups.track,
sequence: Number(code.groups.seq)
})
)One definition now validates the format, extracts the parts, and returns a typed object.
Step 8: Defaults, Narrows, and Brands
Defaults use = in the definition. A key with a default is optional on input and required on output:
const TalkSettings = type({
isRecorded: "boolean = true",
maxAttendees: "number.integer = 100",
track: "'ai' | 'web' = 'web'"
})
TalkSettings({}) // { isRecorded: true, maxAttendees: 100, track: "web" }For non-primitive defaults, pass a factory so instances are not shared:
const Draft = type({
tags: type("string[]").default(() => [])
})Narrows attach custom predicates that run after structural validation. Use the : tuple form:
const EventWindow = type({
startsAt: "string.date.parse",
endsAt: "string.date.parse"
}).narrow((window, ctx) =>
window.endsAt > window.startsAt ||
ctx.mustBe("an end date after the start date")
)The narrow receives the fully parsed object, so you are comparing real Date values, not strings.
Brands mark a type as nominal without changing runtime behaviour, using #:
const SeatCount = type("number % 5#seatBlock")
type SeatCount = typeof SeatCount.infer
// a plain `number` will no longer be assignable to SeatCountNow a function taking SeatCount cannot silently accept any number that happened to be lying around — it must come out of the validator.
Step 9: Scopes for Reusable Schema Modules
Once you have more than a handful of types that reference each other, string aliases beat imports. A scope is a namespace of definitions that can refer to one another by name:
import { scope } from "arktype"
export const conference = scope({
// aliases: plain definitions, NOT wrapped in type()
TalkId: "string.uuid",
Track: "'ai' | 'web' | 'devops' | 'design'",
Speaker: {
email: "string.email",
name: "5 <= string <= 80"
},
Talk: {
id: "TalkId",
title: "5 <= string <= 120",
track: "Track",
speakers: "Speaker[] >= 1",
"relatedTalks?": "TalkId[]"
}
})
export const schemas = conference.export()Use the exported module anywhere:
import { schemas } from "@/lib/schemas/conference"
const out = schemas.Talk(payload)
type Talk = typeof schemas.Talk.inferThe single most common ArkType mistake: wrapping scope aliases in type(). Inside scope({...}), write Track: "'ai' | 'web'", never Track: type("'ai' | 'web'"). The wrapped version compiles but breaks alias resolution for anything referencing it.
Scopes also support recursion via this and index signatures:
export const treeScope = scope({
Category: {
name: "string",
"children?": "Category[]"
},
TalksById: {
"[string.uuid]": "Talk | undefined"
}
})Step 10: Validated Functions with type.fn
ArkType 2.2 introduced type.fn, which wraps a function so its parameters and return value are validated at runtime:
import { type } from "arktype"
export const scoreTalk = type.fn(
"string >= 10", // abstract
"number.integer >= 1", // reviewer count
":", // separator before the return type
"0 <= number <= 100"
)((abstract, reviewers) => {
const raw = Math.min(abstract.length / 10, 100)
return Math.round(raw / reviewers) * reviewers
})
scoreTalk("A long enough abstract about agents", 3) // number
scoreTalk("short", 3) // TraversalError: must be at least length 10Parameters support defaults, optionals, and variadics with the same syntax you use in object schemas:
const notify = type.fn(
"string.email",
"string = 'Your talk was received'"
)((to, message) => `${to}: ${message}`)
notify("speaker@noqta.tn") // "speaker@noqta.tn: Your talk was received"This is genuinely useful at trust boundaries: webhook handlers, plugin entry points, and anything an LLM tool call can reach.
Step 11: A Validated Next.js Server Action
Now wire it together. Create app/submit/actions.ts:
"use server"
import { type } from "arktype"
const Submission = type({
title: "5 <= string <= 120",
abstract: "string >= 200",
email: "string.email",
track: "'ai' | 'web' | 'devops' | 'design'",
durationMinutes: "string.integer.parse |> 15 <= number <= 90",
"coAuthor?": "string.email"
})
export type SubmissionState = {
ok: boolean
message?: string
fieldErrors?: Record<string, string>
}
export async function submitTalk(
_prev: SubmissionState,
formData: FormData
): Promise<SubmissionState> {
const result = Submission(Object.fromEntries(formData))
if (result instanceof type.errors) {
const fieldErrors: Record<string, string> = {}
for (const error of result) {
const key = String(error.path[0] ?? "form")
fieldErrors[key] ??= error.message
}
return { ok: false, message: "Please fix the highlighted fields.", fieldErrors }
}
// result.durationMinutes is a number here, already range-checked
await saveTalk(result)
return { ok: true, message: "Submission received." }
}Object.fromEntries(formData) gives you an object of strings. The string.integer.parse |> 15 <= number <= 90 morph converts and range-checks durationMinutes in the schema, so saveTalk receives a real number that is guaranteed to be in range.
The client component consumes it with useActionState:
"use client"
import { useActionState } from "react"
import { submitTalk, type SubmissionState } from "./actions"
const initial: SubmissionState = { ok: false }
export function TalkForm() {
const [state, action, pending] = useActionState(submitTalk, initial)
return (
<form action={action} className="space-y-4">
<label className="block">
<span>Title</span>
<input name="title" className="input" />
{state.fieldErrors?.title && (
<p className="text-red-500 text-sm">{state.fieldErrors.title}</p>
)}
</label>
<label className="block">
<span>Duration (minutes)</span>
<input name="durationMinutes" type="number" className="input" />
{state.fieldErrors?.durationMinutes && (
<p className="text-red-500 text-sm">
{state.fieldErrors.durationMinutes}
</p>
)}
</label>
<button disabled={pending}>
{pending ? "Submitting…" : "Submit talk"}
</button>
{state.message && <p>{state.message}</p>}
</form>
)
}Client-side validation via Standard Schema
ArkType implements Standard Schema, the shared interface adopted across the validation ecosystem. That means React Hook Form accepts an ArkType type directly through the standard resolver — no ArkType-specific adapter:
import { useForm } from "react-hook-form"
import { standardSchemaResolver } from "@hookform/resolvers/standard-schema"
import { Submission } from "@/lib/schemas/submission"
const form = useForm({
resolver: standardSchemaResolver(Submission)
})Standard Schema also works in reverse: ArkType 2.2 can embed validators from any compliant library inside its own definitions. Migrating a large codebase one schema at a time is therefore a non-event:
import { type } from "arktype"
import { ZodAddress } from "./legacy/address" // still a Zod schema
const Attendee = type({
name: "string",
address: ZodAddress // works as-is
})Step 12: Environment Variables and JSON Schema
Validate environment variables once, at module load, so a missing key crashes at boot rather than on a customer's request. Create lib/env.ts:
import { type } from "arktype"
const Env = type({
NODE_ENV: "'development' | 'test' | 'production'",
DATABASE_URL: "string.url",
RESEND_API_KEY: "string >= 20",
"SENTRY_DSN?": "string.url",
MAX_UPLOAD_MB: "string.integer.parse |> 1 <= number <= 50"
})
export const env = Env.assert(process.env)Import env instead of touching process.env anywhere else. MAX_UPLOAD_MB is a validated number by the time any code reads it.
For a public API, generate JSON Schema from the same definition using @ark/json-schema:
import { Submission } from "@/lib/schemas/submission"
export function GET() {
return Response.json(Submission.toJsonSchema())
}The conversion is bidirectional — you can also parse an existing JSON Schema into an ArkType type, with configurable fallbacks for constructs JSON Schema expresses but ArkType does not. That makes it practical to adopt ArkType behind an OpenAPI contract you do not control.
Testing Your Implementation
Add Vitest and assert on both branches:
npm install -D vitest// lib/schemas/talk.test.ts
import { describe, it, expect } from "vitest"
import { type } from "arktype"
import { Submission } from "./submission"
const valid = {
title: "Shipping AI Agents in Production",
abstract: "x".repeat(250),
email: "speaker@noqta.tn",
track: "ai",
durationMinutes: "45"
}
describe("Submission", () => {
it("parses duration into a number", () => {
const out = Submission(valid)
expect(out).not.toBeInstanceOf(type.errors)
if (!(out instanceof type.errors)) {
expect(out.durationMinutes).toBe(45)
expect(typeof out.durationMinutes).toBe("number")
}
})
it("rejects an unknown track with a field path", () => {
const out = Submission({ ...valid, track: "quantum" })
expect(out).toBeInstanceOf(type.errors)
if (out instanceof type.errors) {
expect([...out][0].path).toEqual(["track"])
}
})
it("enforces the duration ceiling", () => {
const out = Submission({ ...valid, durationMinutes: "500" })
expect(out).toBeInstanceOf(type.errors)
})
})Run npx vitest run. Three green tests confirm the morph, the union, and the range bound.
A useful habit: add a type-level assertion so schema drift breaks the build.
import type { Equals } from "arktype/internal/utils"
type Out = typeof Submission.infer
const _check: Equals<Out["durationMinutes"], number> = trueTroubleshooting
"Type instantiation is excessively deep" — usually a very large union or a deeply recursive scope. Split the schema into a scope with named aliases; ArkType caches alias resolution and the depth pressure drops.
Editor autocomplete is slow in a big schema file — upgrade to TypeScript 5.9+ and make sure skipLibCheck is true. ArkType's inference is heavy by design, and older compilers pay for it.
Regex never matches — you almost certainly single-escaped inside a string literal. "/\d+/" is wrong; "/\\d+/" is right.
groups is undefined on a regex match — you used a plain /.../ literal instead of the x/.../ prefix. Only the x/ form participates in type-level parsing.
A scope alias resolves to unknown — the alias was wrapped in type(). Unwrap it.
Default value is shared between objects — you passed a literal array or object as a default. Pass a factory function instead: .default(() => []).
Server Action always rejects a numeric field — remember FormData values are strings. Use string.integer.parse or string.numeric.parse rather than number.
Next Steps
- Compare approaches with our Zod v4 schema validation guide and the Valibot modular validation tutorial — all three implement Standard Schema, so they interoperate.
- Feed ArkType's JSON Schema output into structured LLM outputs; see BAML for type-safe structured LLM outputs.
- Pair scopes with type-safe API routes using oRPC for validated contracts on both ends of the wire.
- Wrap LLM tool handlers in
type.fnso a hallucinated argument is rejected before it reaches your database.
Conclusion
ArkType's pitch is narrow and specific: your validator should look like your types, because it is your types. In practice that translates into four concrete wins.
You write less. "5 <= string <= 120" replaces a builder chain, and the definition reads the way you would describe the constraint out loud.
You catch more at edit time. Definition strings are checked by the TypeScript compiler, so a typo in a schema is a red squiggle, not a runtime surprise. Typed regex capture groups extend that guarantee into territory no other validator reaches.
You parse instead of validate. Morphs push FormData strings, ISO dates, and numeric env vars into real domain values at the boundary, so nothing downstream handles an unparsed shape.
And you are not locked in. Standard Schema means ArkType composes with Zod and Valibot schemas in both directions, and @ark/json-schema bridges to the wider OpenAPI world — so adoption can be incremental, schema by schema, starting with the one form you are least happy with today.