docs/.mintlify/skills/framework-integration/references/schema-validation.md
@novu/framework accepts three kinds of schemas for both payloadSchema (workflow level) and controlSchema (step level):
| Schema | Best for | Type inference |
|---|---|---|
| Zod | TypeScript-first projects | Best — automatic via z.infer |
| JSON Schema | OpenAPI-style projects, advanced features (oneOf, if/then/else, $ref) | Good — requires as const |
| Class Validator | OOP-style apps, NestJS DTOs | Requires class-validator-jsonschema and reflect-metadata |
All three are converted to JSON Schema under the hood and pushed to Novu Cloud — so the Dashboard always renders the same UI.
Novu supports Zod v3.
npm install zod
import { workflow } from "@novu/framework";
import { z } from "zod";
export const commentWorkflow = workflow(
"comment-on-post",
async ({ step, payload }) => {
await step.email("send-email", async () => ({
subject: `New comment from ${payload.authorName}`,
body: payload.comment,
}));
},
{
payloadSchema: z.object({
postId: z.number(),
authorName: z.string(),
comment: z.string().max(200),
}),
}
);
await step.email("send-email", async (controls) => ({
subject: controls.subject,
body: render(<EmailTemplate hideBanner={controls.hideBanner} />),
}), {
controlSchema: z.object({
hideBanner: z.boolean().default(false),
subject: z.string().default("Hi {{subscriber.firstName | capitalize}}"),
components: z.array(
z.object({
type: z.enum(["header", "cta-row", "footer"]),
content: z.string(),
})
),
}),
});
| Zod feature | Dashboard input |
|---|---|
z.string() | Text input |
z.string().email() | Email input with validation |
z.string().url() | URL input |
z.string().regex(...) | Text input with pattern validation |
z.string().min/.max | Length-validated input |
z.number() | Number input |
z.boolean() | Toggle |
z.enum([...]) | Dropdown |
z.array(...) | Repeatable section |
.default(value) | Pre-filled value |
Zod doesn't support custom
titleon fields — the Dashboard label is derived from the property name.
Use JSON Schema when you need features Zod doesn't expose: oneOf, if/then/else, $ref, enumNames, etc.
workflow("comment", handler, {
payloadSchema: {
type: "object",
properties: {
postId: { type: "number" },
authorName: { type: "string" },
comment: { type: "string", maxLength: 200 },
},
required: ["postId", "comment"],
additionalProperties: false,
} as const, // CRITICAL: required for TS inference
});
Without
as const, TypeScript infersstringfortypeinstead of the literal"object", and you lose type inference onpayload.
{
"type": "object",
"required": ["firstName", "lastName"],
"properties": {
"firstName": { "type": "string", "title": "First name", "default": "Chuck" },
"lastName": { "type": "string", "title": "Last name" },
"age": { "type": "integer", "title": "Age" }
}
}
{
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": { "type": "string" },
"done": { "type": "boolean", "default": false }
}
}
}
}
}
$ref reuse{
"definitions": {
"address": {
"type": "object",
"properties": {
"street": { "type": "string" },
"city": { "type": "string" }
}
}
},
"type": "object",
"properties": {
"billing": { "$ref": "#/definitions/address" },
"shipping": { "$ref": "#/definitions/address" }
}
}
oneOf discriminated union{
"type": "object",
"oneOf": [
{
"properties": { "lorem": { "type": "string" } },
"required": ["lorem"]
},
{
"properties": { "ipsum": { "type": "string" } },
"required": ["ipsum"]
}
]
}
if/then/else{
"type": "object",
"properties": {
"animal": { "enum": ["Cat", "Fish"] }
},
"allOf": [
{
"if": { "properties": { "animal": { "const": "Cat" } } },
"then": {
"properties": { "food": { "enum": ["meat", "grass", "fish"] } },
"required": ["food"]
}
},
{
"if": { "properties": { "animal": { "const": "Fish" } } },
"then": {
"properties": {
"food": { "enum": ["insect", "worms"] },
"water": { "enum": ["lake", "sea"] }
},
"required": ["food", "water"]
}
}
]
}
{
"type": "object",
"properties": {
"phone": {
"type": "string",
"pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$"
}
}
}
For OOP-style projects (especially NestJS DTOs).
npm install class-validator class-validator-jsonschema reflect-metadata
class-validator-jsonschemais required to convert decorators to JSON Schema.reflect-metadatamust be imported once at app entry (import "reflect-metadata").
import { workflow } from "@novu/framework";
import {
IsString,
IsBoolean,
IsNotEmpty,
IsOptional,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
class WelcomeComponent {
@IsString()
subject!: string;
@IsString()
content!: string;
}
class WelcomeControlSchema {
@IsBoolean()
hideBanner!: boolean;
@IsString()
@IsNotEmpty()
@IsOptional()
subject?: string;
@Type(() => WelcomeComponent)
@ValidateNested({ each: true })
@IsOptional()
components?: WelcomeComponent[];
}
class WelcomePayloadSchema {
@IsString()
username!: string;
}
export const welcomeWorkflow = workflow(
"welcome",
async ({ step, payload }) => {
await step.email("send-email", async (controls) => ({
subject: controls.subject ?? `Welcome ${payload.username}`,
body: "Hello!",
}), { controlSchema: WelcomeControlSchema });
},
{ payloadSchema: WelcomePayloadSchema }
);
class-validator-jsonschema docs.| You want… | Use |
|---|---|
| Best DX, type inference, Vercel-style validation | Zod |
| Already use NestJS / DTOs | Class Validator |
Need oneOf, $ref, if/then/else, share schemas with API consumers | JSON Schema |
| Want only IDE intellisense (no Dashboard schema) | Plain TS interfaces — but you lose Dashboard form generation |