Standard Schema
Type-safe validation with Zod, Valibot, or ArkType
Better-Notify validates procedure input through Standard Schema — a shared interface that Zod, Valibot, ArkType, and other validators implement. This means @betternotify/core has zero hard dependency on any specific validation library. You bring the validator you already use.
How it works
When you call .input(schema) on a procedure builder, the schema is stored as-is. At send time, Better-Notify calls the Standard Schema ~standard.validate() method on the input. If validation passes, the coerced output flows into the middleware chain and render function. If it fails, a NotifyRpcValidationError is thrown before any middleware runs.
const welcome = rpc
.email()
.input(z.object({ name: z.string(), email: z.string().email() }))
.subject(({ input }) => `Welcome, ${input.name}!`)
.template(adapter);The input type is inferred from the schema — { name: string; email: string } in this case. The builder, middleware, template, and client all see the same narrowed type.
Supported validators
Any library that implements the Standard Schema interface works. The three most common:
Zod
The most popular choice. Zod 3.24+ implements Standard Schema natively.
Valibot
Lightweight alternative with tree-shakeable design. Standard Schema support built in.
ArkType
Type-first validator with runtime performance focus. Standard Schema compatible.
TypeBox
JSON Schema based validator with high runtime performance. Standard Schema via @typebox/standard.
Zod
import { z } from 'zod';
rpc.email().input(z.object({
name: z.string().min(1),
verifyUrl: z.string().url(),
}));Valibot
import * as v from 'valibot';
rpc.email().input(v.object({
name: v.pipe(v.string(), v.minLength(1)),
verifyUrl: v.pipe(v.string(), v.url()),
}));ArkType
import { type } from 'arktype';
rpc.email().input(type({
name: 'string > 0',
verifyUrl: 'string.url',
}));TypeBox
import { Type } from '@sinclair/typebox';
rpc.email().input(Type.Object({
name: Type.String({ minLength: 1 }),
verifyUrl: Type.String({ format: 'uri' }),
}));Validation errors
When input fails validation, Better-Notify throws NotifyRpcValidationError with structured issues from the validator:
import { NotifyRpcValidationError } from '@betternotify/core';
try {
await mail.welcome.send({
to: 'ada@example.com',
input: { name: '', verifyUrl: 'not-a-url' },
});
} catch (err) {
if (err instanceof NotifyRpcValidationError) {
console.log(err.issues);
// [{ message: '...', path: ['name'] }, { message: '...', path: ['verifyUrl'] }]
}
}Prop
Type
Validation runs before hooks or middleware — a failed input never reaches onBeforeSend or any middleware in the chain. The error routes directly to onError hooks with phase: 'validate'.
Channel-level validation
Channels also validate send arguments (like to, cc, bcc) independently from the input schema. Channel validation uses either a Standard Schema or a plain function, depending on the channel implementation:
- Email: validates that
tois present via a function - SMS: validates that
tois a non-empty string via a function - Push: validates that
tois a non-empty string or array via a function - Custom channels: can use either a Standard Schema or a function via
validateArgs
const slackChannel = defineChannel({
name: 'slack',
slots: { text: slot.resolver<string>() },
validateArgs: z.object({
channel: z.string(),
threadTs: z.string().optional(),
}),
render: ({ runtime, args }) => ({ channel: args.channel, text: runtime.text }),
});When validateArgs is a Standard Schema, the same ~standard.validate() flow applies — structured issues, type inference, and NotifyRpcValidationError on failure.