Skip to content

Creating Zod Schemas for Commands and Queries

When you validate Xeno.JS commands and queries with Zod, use ZodUtils to create the schema instead of manually rebuilding the request metadata.

ZodUtils is exported by @xeno-js/shared and provides:

  • createCommandSchema() for commands;
  • createQuerySchema() for queries.

Both helpers combine your application-specific fields with the Xeno.JS base schema and return a strict Zod object.

This means you do not need to manually add:

  • intent;
  • type;
  • query cacheOptions.

You need:

  • @xeno-js/shared;
  • zod;
  • a Command or Query;
  • the Zod validation pipeline enabled;
  • a schema registered under the same intent as the request.

For validation configuration, see Validation Pipeline.


Use ZodUtils.createCommandSchema() when creating a schema for a Command.

import { z } from 'zod'
import { ZodUtils } from '@xeno-js/shared'
const createUserSchema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)

The second argument is the command-specific Zod shape.

You should not wrap it in z.object().

The resulting schema includes the fields required by the Xeno.JS command contract:

{
intent: z.literal('CreateUserCommand'),
type: z.literal(REQUEST_TYPE.COMMAND),
email: z.string().email(),
name: z.string().min(1),
}

You only define the fields belonging to your command.

This avoids repeating request metadata in every schema.


Use ZodUtils.createQuerySchema() for a Query.

import { z } from 'zod'
import { ZodUtils } from '@xeno-js/shared'
const getUserSchema = ZodUtils.createQuerySchema(
'GetUserQuery',
{
userId: z.string().uuid(),
},
)

The generated schema includes:

{
intent: z.literal('GetUserQuery'),
type: z.literal(REQUEST_TYPE.QUERY),
cacheOptions: {
cacheKey: z.string().min(1),
ttl: z.number().int().positive().optional(),
bypassCache: z.boolean().optional(),
consistentRead: z.boolean().optional(),
isUserScoped: z.boolean()
},
userId: z.string().uuid(),
}

You therefore do not need to add intent, type, or cacheOptions to your application-specific shape.


A Xeno.JS Query contains cacheOptions.

For example:

import { Query } from '@xeno-js/shared'
export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false
})
}
}

The generated query schema validates these cache options automatically.

Property Required Description
cacheKey Yes Key used to identify the cached result.
ttl No Cache lifetime in seconds.
bypassCache No Bypasses a cached value for the request.
consistentRead No Requests a read that bypasses the cached value.
isUserScoped yes Whether the cache is user-scoped.

cacheKey must be a non-empty string.

ttl, when provided, must be a positive integer.


The purpose of ZodUtils is to let the schema focus on the data defined by your command or query.

For example, given:

export class CreateUserCommand extends Command<User> {
constructor(
public readonly email: string,
public readonly name: string,
) {
super('CreateUserCommand')
}
}

create the schema with:

const createUserSchema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)

Do not repeat:

intent: z.literal('CreateUserCommand'),
type: z.literal(...),

Those fields are provided by ZodUtils.


ZodUtils expects a ZodRawShape, not an already-created Zod object.

Use:

const schema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)

Not:

const schema = ZodUtils.createCommandSchema(
'CreateUserCommand',
z.object({
email: z.string().email(),
name: z.string().min(1),
}),
)

The helper creates and extends the base Zod object itself.


Once the schema is created, register it under the same command or query intent.

For example:

import { z } from 'zod'
import { AppBuilder } from '@xeno-js/core'
import { ZodUtils } from '@xeno-js/shared'
const createUserSchema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)
const getUserSchema = ZodUtils.createQuerySchema(
'GetUserQuery',
{
userId: z.string().uuid(),
},
)
const app = new AppBuilder()
.addPipeline((config) => {
config.validation.zod = {
schemas: {
CreateUserCommand: createUserSchema,
GetUserQuery: getUserSchema,
},
}
})

The schema registry is keyed by request intent.

Therefore:

super('CreateUserCommand')

must correspond to:

CreateUserCommand: createUserSchema

Likewise:

super('GetUserQuery', ...)

must correspond to:

GetUserQuery: getUserSchema

A command schema can be kept next to the command:

src/
└── application/
└── users/
├── create-user.command.ts
└── create-user.schema.ts
import { Command } from '@xeno-js/shared'
export class CreateUserCommand extends Command<User> {
constructor(
public readonly email: string,
public readonly name: string,
) {
super('CreateUserCommand')
}
}
import { z } from 'zod'
import { ZodUtils } from '@xeno-js/shared'
export const createUserSchema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)
import { AppBuilder } from '@xeno-js/core'
import { createUserSchema } from './application/users/create-user.schema'
const app = new AppBuilder()
.addPipeline((config) => {
config.validation.zod = {
schemas: {
CreateUserCommand: createUserSchema,
},
}
})
await app.build()

For a query, define the query-specific fields and let ZodUtils add the request and cache fields.

src/
└── application/
└── users/
├── get-user.query.ts
└── get-user.schema.ts
import { Query } from '@xeno-js/shared'
export class GetUserQuery extends Query<User> {
constructor(
public readonly userId: string,
) {
super('GetUserQuery', {
cacheKey: `user:${userId}`,
ttl: 60,
bypassCache: false,
consistentRead: false,
isUserScoped: false,
})
}
}
import { z } from 'zod'
import { ZodUtils } from '@xeno-js/shared'
export const getUserSchema = ZodUtils.createQuerySchema(
'GetUserQuery',
{
userId: z.string().uuid(),
},
)
import { AppBuilder } from '@xeno-js/core'
import { getUserSchema } from './application/users/get-user.schema'
const app = new AppBuilder()
.addPipeline((config) => {
config.validation.zod = {
schemas: {
GetUserQuery: getUserSchema,
},
}
})
await app.build()

ZodUtils calls .strict() on the resulting schema.

This means properties that are not part of the generated schema are rejected.

For example:

const schema = ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
},
)

The schema does not accept an arbitrary additional property such as:

{
email: 'john@example.com',
unexpectedField: true,
}

Define every application-specific property that the request is expected to contain.

You should also avoid manually adding the base properties because ZodUtils already provides them.


Check that the intent used by the request exactly matches the intent passed to ZodUtils.

For example:

super('CreateUserCommand')

must use:

ZodUtils.createCommandSchema('CreateUserCommand', ...)

Intent matching is exact.


Do not manually define the type field.

Use the correct helper:

ZodUtils.createCommandSchema(...)

for commands and:

ZodUtils.createQuerySchema(...)

for queries.

The helpers add the appropriate request type automatically.


Check that the query provides a cacheOptions object with a non-empty:

cacheKey

For example:

super('GetUserQuery', {
cacheKey: `user:${userId}`,
})

If ttl is provided, it must be a positive integer.


The generated schema is strict.

Make sure the property is included in the shape passed to ZodUtils.

For example:

ZodUtils.createQuerySchema('GetUserQuery', {
userId: z.string().uuid(),
})

If the request contains another application-specific property, add it to the shape.


Check:

  1. the Zod validation pipeline is enabled;
  2. the schema is registered under the request intent;
  3. the request’s intent exactly matches the registry key;
  4. the request is executed through the configured mediator;
  5. the installed @xeno-js/shared version exposes ZodUtils.

See Validation Pipeline for the complete validation configuration.


Use ZodUtils whenever you create a Zod schema for a Xeno.JS CQRS command or query.

It keeps the schema definition focused on application data:

ZodUtils.createCommandSchema(
'CreateUserCommand',
{
email: z.string().email(),
name: z.string().min(1),
},
)

instead of duplicating Xeno.JS request metadata:

z.object({
intent: z.literal('CreateUserCommand'),
type: ...,
email: z.string().email(),
name: z.string().min(1),
})

For queries, it also supplies the base cacheOptions schema.



Xeno.JS is an MIT-licensed open source project. It can grow thanks to the support of these awesome people. If you’d like to join them, please read more at support section