Skip to content

TypeGen — Zod emitter (runtime validation)

TypeTarget.Zod emits Zod schemas — TypeScript source that ships a runtime validator alongside an inferred type. Pairs with the TypeScript target (both can emit in parallel, independent files, zero coupling) or runs standalone when you want only Zod without a separate interface file.

[GenerateTypes(Targets = TypeTarget.TypeScript | TypeTarget.Zod,
OutputDir = "../client/src/api")]
public class Order
{
public int Id { get; set; }
[ZEmail] public string Email { get; set; } = "";
[ZRange(1, 100)] public int Qty { get; set; }
public OrderStatus Status { get; set; }
}

→ Order.ts (TypeScript emitter, unchanged):

export interface Order {
id: number;
email: string;
qty: number;
status: OrderStatus;
}

→ Order.schema.ts (Zod emitter, new):

import { z } from 'zod';
import { OrderStatusSchema } from './OrderStatus.schema';
export const OrderSchema = z.object({
id: z.number().int(),
email: z.email(),
qty: z.number().int().gte(1).lte(100),
status: OrderStatusSchema,
});
export type Order = z.infer<typeof OrderSchema>;

Independent from TypeScript emitter by default. Both files are generated from the same SchemaModel and regenerate together on the next build. The TS interface stays as the ergonomic type-only view (cheap to import, no runtime dep); the Zod schema carries the runtime validator and its own z.infer alias for Zod-only consumers. Enable ConformToTypeScriptTypes when you also want z.toZod<T>() to make the TypeScript compiler prove that both generated shapes match.

Same attributes that drive OpenAPI constraints map to Zod chained calls:

C#Zod
[MinLength(n)], [ZMinLength(n)].min(n)
[MaxLength(n)], [ZMaxLength(n)].max(n)
[Range(min, max)], [ZRange(min, max)].gte(min).lte(max)
[RegularExpression("pat")], [ZMatch("pat")].regex(/pat/)
[EmailAddress], [ZEmail]z.email()
[Url], [ZUrl]z.url()
[CreditCard], [ZCreditCard]z.creditCard()
[ZIban]z.iban()
System.Guidz.uuid()
System.DateTimez.iso.datetime()
System.DateOnlyz.iso.date()

The emitter targets Zod 4 — it emits the top-level format factories (z.uuid(), z.email(), z.iso.datetime(), …) rather than the chained z.string().uuid() forms that Zod 4 deprecated. Install Zod 4: npm install zod@^4.

Zod-only formats can be selected without changing the OpenAPI contract:

[ZodFormat(ZodStringFormat.Ulid)]
public string PublicId { get; set; } = "";
b.ForType<Payment>()
.Property(x => x.CardToken)
.ZodFormat(ZodStringFormat.Base64Url);
// Zod 4.6 custom NanoID length:
b.ForType<Payment>()
.Property(x => x.PublicToken)
.ZodNanoId(16);

Available formats are email, URL, UUID, date, date-time, hostname, ULID, NanoID, Base64, Base64URL, credit card, and IBAN.

[TsType] controls only the static TypeScript type; it cannot provide runtime validation. Use [ZodSchema] when a third-party package or shared frontend module already exports the runtime schema for a C# property:

public sealed class OrderDto
{
[TsType("Point", ImportFrom = "geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public Point? DeliveryAreaPoint { get; init; }
[TsType("Feature", ImportFrom = "geojson")]
[ZodSchema("GeoJSONFeatureSchema", ImportFrom = "zod-geojson")]
public Feature? DeliveryAreaFeature { get; init; }
}

The Zod file imports and uses those schemas directly:

import { GeoJSONFeatureSchema, GeoJSONPointSchema } from 'zod-geojson';
export const OrderDtoSchema = z.object({
deliveryAreaPoint: (GeoJSONPointSchema).nullish(),
deliveryAreaFeature: (GeoJSONFeatureSchema).nullish(),
});

Install the referenced runtime package in the generated client’s project:

Terminal window
npm install zod zod-geojson
npm install --save-dev @types/geojson

The same override is available when the C# model cannot be annotated:

b.ForType<OrderDto>()
.Property(x => x.DeliveryAreaPoint)
.ZodSchema("GeoJSONPointSchema", "zod-geojson");

The schema expression is emitted verbatim. Omit ImportFrom for inline expressions such as z.string().startsWith('ord_'). TypeGen still applies the property’s nullable, optional, read-only, and PatchField<T> modifier after the override. Other inferred validation constraints are not appended because the explicit schema owns validation for that property.

When ImportFrom is set, a bare identifier supplies its own import name. For compound expressions, specify the exported name explicitly:

[ZodSchema("GeoJSONPointSchema.refine(point => point.coordinates.length >= 2)",
ImportFrom = "zod-geojson", Import = "GeoJSONPointSchema")]
public Point? DeliveryAreaPoint { get; init; }
// Or on a referenced type:
b.ForType<OrderDto>()
.Property(x => x.DeliveryAreaPoint)
.ZodSchema("GeoJSONPointSchema.refine(point => point.coordinates.length >= 2)",
"zod-geojson", "GeoJSONPointSchema");

TypeGen reports TG0021 if a compound expression has ImportFrom without an explicit import name. It aliases external imports when their names clash with a generated schema or another module. Override expressions that refer to generated schemas are included in import, ordering, and cycle analysis; cyclic references are emitted through z.lazy(...).

Imports sharing a module are grouped and deduplicated in both single-file and file-per-class output. External schemas also participate normally in z.compile(...), generated guards, and TanStack payload parsing. With ConformToTypeScriptTypes, the external schema’s inferred type must exactly match the [TsType] expression—not merely be structurally assignable. For zod-geojson, use its own inferred aliases when exact conformance is enabled:

[TsType("GeoJSONPoint", ImportFrom = "zod-geojson")]
[ZodSchema("GeoJSONPointSchema", ImportFrom = "zod-geojson")]
public Point? DeliveryAreaPoint { get; init; }
C#Zod
int, long, short, bytez.number().int()
float, doublez.number()
decimalz.string() (precision-preserving, matches TS)
stringz.string()
boolz.boolean()
T? (nullable).nullish() (null ∪ undefined ∪ absent)
PatchField<T>T.optional() (omission is distinct from an explicit value)
List<T>, T[]z.array(T)
Dictionary<string, V>z.record(z.string(), V)
user DTOdirect ref {Name}Schema (cross-file import)
[ZodSchema("X", ImportFrom = "pkg")]imported runtime schema X
numeric enumz.union([z.literal(0), z.literal(1), …])
enum + [JsonStringEnumConverter]z.enum(['A', 'B', …])

[JsonPolymorphic] + [JsonDerivedType] on the C# side produces a z.discriminatedUnion — matching the TypeScript discriminated-union semantics exactly, with exhaustive narrowing from the discriminator literal:

export const ShapeSchema = z.discriminatedUnion('kind', [
CircleSchema,
SquareSchema,
]);
export type Shape = z.infer<typeof ShapeSchema>;
// In CircleSchema:
export const CircleSchema = z.object({
kind: z.literal('circle'),
radius: z.number(),
});

When the base class is in the emit set, derived schemas compose via .extend():

// Derived class Order : Entity
export const OrderSchema = EntitySchema.extend({
customer: z.string(),
});
b.Zod(z =>
{
z.OutputDir = "../client/src/validation";
z.FileLayout = ZodFileLayout.SingleFile;
z.SingleFileName = "schemas.ts";
z.SchemaConstSuffix = "Schema"; // default; "XxxSchema"
z.EmitInferredTypes = true; // default; adds `export type X = z.infer<…>`
z.FileSuffix = ".schema"; // `Order.schema.ts` avoids collision with TS's `Order.ts`
z.ConformToTypeScriptTypes = true; // opt-in `z.toZod<T>()` compile-time check
z.Compilation = ZodCompilationMode.Compile; // opt-in optimized parser
z.EmitValidationGuards = true; // emits `isOrder(value)` using `.validate()`
});

Recursive DTO references are emitted with z.lazy(...), including mutually recursive types split across files. Compilation is opt-in because Zod uses generated JavaScript internally; unsupported schemas safely fall back to the normal parser.

Consumer install: the emitted code imports zod — add it to the frontend project: npm install zod@^4.6.1. The emitter targets Zod 4’s top-level format factories. TypeGen doesn’t bundle or generate the dep.