Guide

Validation

Use Standard Schema validators, preserve Zod compatibility, and infer transformed inputs and outputs.

Validation

.schema() and .outputSchema() accept Standard Schema v1 validators. Zod 3.20+ remains supported through async legacy parsing when Standard Schema is unavailable. The module does not bundle a validator runtime.

ValidatorIntegration
Zod 3.20–3.23Legacy safeParseAsync
Zod 3.24+ / Zod 4Standard Schema v1
Valibot / ArkTypeStandard Schema v1
Yup 1.7+Native Standard Schema v1
Other validatorsMust implement Standard Schema v1

Install the validator separately. CI includes real Zod, Valibot, ArkType, and Yup schema tests; a custom Standard Schema implementation should preserve its declared input/output types and return issues rather than throw for expected validation failures.

// pnpm add valibot
import * as v from 'valibot'
import { createSafeActionClient } from '#safe-action'

export default createSafeActionClient()
  .schema(v.object({ name: v.pipe(v.string(), v.minLength(1)) }))
  .action(({ parsedInput }) => ({ greeting: `Hello, ${parsedInput.name}!` }))
// pnpm add arktype
import { type } from 'arktype'
import { createSafeActionClient } from '#safe-action'

export default createSafeActionClient()
  .schema(type({ name: 'string > 0' }))
  .action(({ parsedInput }) => ({ greeting: `Hello, ${parsedInput.name}!` }))

Yup

Install yup@^1.7.0 or newer. Yup 1.7.0 introduced native Standard Schema support, so pass Yup schemas directly to the existing builder. No adapter, global validator setting, or mandatory module dependency is needed.

server/actions/update-account.ts
import * as yup from 'yup'
import { createSafeActionClient } from '#safe-action'

export default createSafeActionClient()
  .schema(yup.object({
    name: yup.string().trim().min(2).required(),
    age: yup.number().integer().min(18).required(),
  }).required())
  .outputSchema(yup.object({
    name: yup.string().trim().required(),
    age: yup.number().integer().required(),
  }).stripUnknown().required())
  .action(({ parsedInput }) => ({
    name: parsedInput.name,
    age: parsedInput.age,
  }))

The client and handler use Yup's declared types: in this example, name: string and age: number. Yup casts/transforms values during normal validation, so a raw HTTP numeric string may be accepted and cast at runtime. That does not widen the inferred typed client input to string | number; send a number from typed client code. Arbitrary Yup .transform() callbacks also do not automatically advertise a different TypeScript output type. Use schema-declared input/output types when assessing inference.

The output schema trims the name and removes unknown output properties with .stripUnknown(). Yup also supports .strip() on individual object fields. Unknown-key behavior is schema-specific: use an explicit public return allowlist as above, rather than assuming every validator strips fields. .strict() disables normal casting/transforms/default application; choose it when you want to require already-parsed values.

In strict mode, do not rely on a default or its inferred non-optional type to guarantee a value exists: yup.number().default(2).strict() can validate undefined to undefined while declaring a number output. Use .defined() or .required() when existence is required, for example yup.number().strict().defined(). Strict validation also skips stripping transforms such as .stripUnknown(); use .noUnknown().strict() to reject extra object keys rather than expecting them to be removed. These are Yup schema semantics, so select an explicit schema contract instead of adding an adapter.

Defaults and asynchronous tests

server/actions/check-name.ts
import * as yup from 'yup'
import { createSafeActionClient } from '#safe-action'

export default createSafeActionClient()
  .schema(yup.object({
    name: yup.string().trim().required().test(
      'available',
      'This name is reserved',
      async (name) => {
        // Demonstration rule; replace with your own application lookup.
        await Promise.resolve()
        return name.toLowerCase() !== 'admin'
      },
    ),
    newsletter: yup.boolean().default(false),
  }).required())
  .action(({ parsedInput }) => ({
    name: parsedInput.name,
    newsletter: parsedInput.newsletter,
  }))

Native Yup validation awaits async tests and collects issues across fields. Defaults are applied during non-strict parsing. Requiredness/default flags can make Yup's declared input and output types differ; follow the generated action input type when choosing form initial values.

Nested object/array issues map to the same dotted field errors used by the other validators, for example profile.name and addresses.0.city; root issues use _root. Existing useAction, useFormAction, and error handling require no validator-specific changes.

Each action may use its own validator. Input and output schemas can also come from different Standard Schema libraries; the handler receives the input schema's parsed output, and the client receives the output schema's parsed output. Existing middleware and metadata APIs remain unchanged.

Transforms and defaults

server/actions/count.ts
import { z } from 'zod'
import { createSafeActionClient } from '#safe-action'

export default createSafeActionClient()
  .schema(z.object({ count: z.string().regex(/^\d+$/).default('1').transform(Number) }))
  .outputSchema(z.object({ count: z.number().finite() })
    .transform(value => ({ label: `Count: ${value.count}` })))
  .action(({ parsedInput }) => ({ count: parsedInput.count }))

The client sends { count?: string }; the handler receives { count: number }. The client output is { label: string }, reflecting the output-schema transform. Defaults make client input optional while guaranteeing parsed values. Output-validation failure returns a generic server error by default. Handlers should return values accepted by the output schema; runtime output validation enforces that contract.

Asynchronous refinements

const nameSchema = z.string().refine(async (name) => {
  // Demonstration rule; replace with an appropriate application lookup.
  await Promise.resolve()
  return name.toLowerCase() !== 'admin'
}, { message: 'This name is reserved' })

export default createSafeActionClient()
  .schema(z.object({ name: nameSchema }))
  .action(({ parsedInput }) => ({ name: parsedInput.name }))

Both input and output validation await asynchronous results. Expected validation issues become dotted field errors; root issues use _root. Exceptions thrown by a schema are unexpected server errors.

JSON transport

Type inference describes schema contracts; it cannot make arbitrary JavaScript values survive JSON. Send plain serializable values. Represent Dates as ISO strings with matching schemas. Functions, cyclic objects, bigint, and file objects require an explicit application transport rather than ordinary action JSON.

Standard Schema specification · Zod · Valibot · ArkType · Yup

Copyright © 2026