openapi-to-zod
@spartaficial/openapi-to-zod turns an OpenAPI 3.x specification into Zod v4 schemas with matching TypeScript types, so that requests and responses can be validated at run time against exactly what the specification says. This page is for developers who validate API data in tests or application code, and it covers every option the generator has and every construct it knows how to translate.
What it generates
Take a specification, simple.yaml, with three schemas:
openapi: 3.0.0
info:
title: Simple Test API
version: 1.0.0
components:
schemas:
User:
type: object
description: A user in the system
properties:
id:
type: string
format: uuid
description: The unique identifier for the user
name:
type: string
description: The user's full name
email:
type: string
format: email
description: The user's email address
age:
type: integer
description: The user's age in years
isActive:
type: boolean
description: Whether the user account is active
required: [id, name]
Status:
type: string
enum: [active, inactive, pending]
Address:
type: object
properties:
street: { type: string }
city: { type: string }
postalCode: { type: string }
and the smallest possible configuration:
// openapi-to-zod.config.ts
import { defineConfig } from "@spartaficial/openapi-to-zod";
export default defineConfig({
specs: [{ input: "simple.yaml", outputTypes: "src/api/schemas.ts" }],
});
npx openapi-to-zod writes src/api/schemas.ts:
// Auto-generated by @spartaficial/openapi-to-zod
// API: Simple Test API
// Version: 1.0.0
// Do not edit this file manually
// Generation Statistics:
// Total schemas: 3
// Circular references: 0
// Discriminated unions: 0
// With constraints: 0
// Generated at: 2026-11-04T18:41:40.884Z
import { z } from "zod";
// Schemas and Types
/** A user in the system */
export const userSchema = z.strictObject({
/** The unique identifier for the user */
id: z.uuid(),
/** The user's full name */
name: z.string(),
/** The user's email address */
email: z.email().optional(),
/** The user's age in years */
age: z.number().int().optional(),
/** Whether the user account is active */
isActive: z.boolean().optional(),
});
export type User = z.infer<typeof userSchema>;
export const statusSchema = z.enum(["active", "inactive", "pending"]);
export type Status = z.infer<typeof statusSchema>;
export const addressSchema = z.strictObject({
street: z.string().optional(),
city: z.string().optional(),
postalCode: z.string().optional(),
});
export type Address = z.infer<typeof addressSchema>;
Each schema becomes a camelCase constant with a Schema suffix and a PascalCase type inferred from it. Objects are z.strictObject() because mode defaults to strict: a property the specification does not describe fails validation, which is usually the drift you want to catch. Properties outside required get .optional(), format: uuid and format: email become Zod's dedicated validators, and integer becomes .int(). Descriptions are kept as JSDoc; the statistics comment is controlled by showStats.
Install
npm install --save-dev @spartaficial/openapi-to-zod zod
npx openapi-to-zod init
npx openapi-to-zod
zod is a peer dependency in the range ^4.0.0: the generated file imports it, so it has to be installed in your project, and the generator uses Zod 4 APIs (z.strictObject, z.uuid, z.iso.datetime) that Zod 3 does not have. Node.js 20 or later is required; see requirements.md. init asks for the specification file, the output path, the configuration format and whether to include a defaults block, then writes openapi-to-zod.config.ts or .json. The bare command generates; there is no generate subcommand. Generation needs a licence key in SPARTAFICIAL_LICENSE_KEY, while compiling and running code that imports the generated schemas never does; see licensing.md.
Configure
The configuration file is { defaults?, specs: [...], executionMode? }, and every spec names an input and an outputTypes. output is still accepted as a deprecated alias for outputTypes; when both are present they must be equal. Discovery and merging are described in configuration-files.md.
import { defineConfig } from "@spartaficial/openapi-to-zod";
export default defineConfig({
defaults: {
mode: "strict",
includeDescriptions: true,
showStats: false,
},
specs: [
{ name: "api-v1", input: "specs/api-v1.yaml", outputTypes: "src/schemas/v1.ts" },
{
name: "api-v2",
input: "specs/api-v2.yaml",
outputTypes: "src/schemas/v2.ts",
mode: "normal",
prefix: "v2",
uuidFormat: "uuidv4",
operationFilters: { excludeDeprecated: true },
},
],
executionMode: "parallel",
});
The options that belong to this generator, each explained in its own section below. The full table is options-zod.md.
| Option | Values | Default | Section |
|---|---|---|---|
mode | strict, normal, loose | strict | Validation modes |
emptyObjectBehavior | strict, loose, record | loose | Validation modes |
request, response | { mode?, useDescribe?, includeDescriptions? } | none | Validation modes |
useDescribe | boolean | false | Descriptions |
customDateTimeFormatRegex | string or RegExp | none (z.iso.datetime()) | Formats |
uuidFormat | uuid, guid, uuidv1 to uuidv8 | uuid | Formats |
schemaType | all, request, response | all | Request and response shapes |
outputZodSchemas | file path | none | Separate types and schemas |
enumFormat | union, const-object | const-object | Separate types and schemas |
typeAssertionThreshold | integer, 0 or more | 0 | Separate types and schemas |
ignoreHeaders | header names or glob patterns | none | Operation schemas |
cacheSize | positive integer | 1000 | Performance |
The shared options behave the same in every generator: includeDescriptions (default true), showStats (default true), showWarnings, fileHeader and batchSize are described in configuration-files.md; prefix, suffix, stripSchemaPrefix, stripPathPrefix and useOperationId (default true) in naming.md; operationFilters in operation-filters.md; defaultNullable in request-and-response-shapes.md; refMappings in specs-and-references.md. executionMode defaults to parallel.
Note that enumFormat accepts only union and const-object here; the enum value exists in openapi-to-typescript and openapi-to-k6 alone, because a TypeScript enum cannot be the declared type of a Zod schema. See option-parity.md for which options each generator accepts.
The specification must declare at least one schema under components.schemas, and every $ref must resolve; a reference to a missing schema fails the run with the schema and property that contain it.
Use the output
The generated schemas are ordinary Zod schemas, and the types are ordinary TypeScript. Validate incoming data with parse or safeParse, and use the types wherever you previously wrote them by hand:
import { userSchema, type User } from "./api/schemas";
const user: User = userSchema.parse(await response.json());
const result = userSchema.safeParse(body);
if (!result.success) {
console.error(result.error.issues);
}
In a test, expect(() => userSchema.parse(body)).not.toThrow() turns an undocumented property or a wrong type into a failing assertion. For a Playwright API client that does this on every call, use zod-playwright.md, which generates its schemas with this package. Generated files contain no licence logic and can be committed.
Validation modes
mode decides what happens to object properties the specification does not mention. It applies to every object in the file, nested ones included.
mode | Objects become | Unknown properties |
|---|---|---|
strict (default) | z.strictObject({…}) | Rejected |
normal | z.object({…}) | Stripped |
loose | z.looseObject({…}) | Kept |
strict is the default because the schemas exist to detect drift between an API and its specification, and a property that nobody documented is the most common form of it. If your API legitimately returns more than it documents, set mode: "normal" in defaults or on that spec. additionalProperties: false on a schema always produces z.strictObject() whatever the mode, and additionalProperties: true or a schema adds .catchall(z.unknown()) or .catchall(schema).
An object with no properties at all is a separate case, controlled by emptyObjectBehavior:
emptyObjectBehavior | { type: object } becomes | Accepts |
|---|---|---|
loose (default) | z.looseObject({}) | Any object |
strict | z.strictObject({}) | Only {} |
record | z.record(z.string(), z.unknown()) | Any object, typed as a map |
The two settings are independent: mode wraps the objects that have properties, emptyObjectBehavior the ones that have none. An empty object that constrains itself with minProperties, maxProperties or propertyNames is not closed by strict mode, because that would make it unsatisfiable; it accepts the keys it constrains and enforces the constraint in a refinement.
Schemas used in only one direction can have their own settings. The generator works out, from paths, whether a component schema appears in request bodies, in responses, or in both, and applies the request block to the first group and the response block to the second; schemas used in both directions, and query and header parameter schemas, use the request block. Each block may set mode, useDescribe and includeDescriptions, and falls back to the top-level value for anything it leaves out. (defaultNullable and emptyObjectBehavior are accepted inside these blocks for compatibility but are read from the top level only.)
export default defineConfig({
specs: [
{
input: "api.yaml",
outputTypes: "src/schemas.ts",
request: { mode: "strict" },
response: { mode: "loose", useDescribe: true },
},
],
});
With that configuration a User returned by GET /users becomes z.looseObject(...) while the CreateUserRequest body it accepts stays z.strictObject(...).
Descriptions
With includeDescriptions at its default of true, a schema's or property's description becomes a JSDoc comment above the declaration; title, examples and deprecated are folded into the same comment, the last as @deprecated. With includeDescriptions: false the comments disappear, except for a bare /** @deprecated */ on deprecated schemas, which editors use to flag them.
useDescribe: true additionally attaches the description to the schema itself with .describe(), so it is available at run time and appears in Zod's error output:
export const userSchema = z
.strictObject({
/** The unique identifier for the user */
id: z.uuid().describe("The unique identifier for the user"),
/** The user's full name */
name: z.string().describe("The user's full name"),
// ...
})
.describe("A user in the system");
useDescribe has no effect when includeDescriptions is false.
Formats
A string format is translated to the matching Zod validator, and length and pattern constraints are chained after it.
OpenAPI format | Generated |
|---|---|
uuid, guid | z.uuid(), or what uuidFormat selects |
email | z.email() |
uri, url | z.url() |
uri-reference | z.string().refine(…) rejecting whitespace |
hostname | z.string().refine(…) with an RFC 1123 pattern |
date | z.iso.date() |
date-time | z.iso.datetime(), or what customDateTimeFormatRegex selects |
time | z.iso.time() |
duration | z.string().refine(…) with an ISO 8601 duration pattern |
ipv4, ipv6 | z.ipv4(), z.ipv6() |
cidr, cidrv4, cidrv6 | z.cidrv4(), z.cidrv4(), z.cidrv6() |
byte, base64 | z.base64() |
base64url | z.base64url() |
binary | z.string() |
emoji, nanoid, cuid, cuid2, ulid | z.emoji(), z.nanoid(), z.cuid(), z.cuid2(), z.ulid() |
json-pointer, relative-json-pointer | z.string().refine(…) with the RFC 6901 grammar |
| anything else | z.string() |
z.iso.datetime() requires a timezone designator, so 2026-01-07T14:30:00Z passes and 2026-01-07T14:30:00 does not. APIs that omit the Z are common; customDateTimeFormatRegex replaces the validator for every date-time field with z.string().regex(…). In a JSON configuration the pattern is a string and backslashes are doubled; in a TypeScript configuration a RegExp literal works too:
export default defineConfig({
defaults: {
customDateTimeFormatRegex: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/,
},
specs: [{ input: "simple.yaml", outputTypes: "src/schemas.ts" }],
});
createdAt: z.string().regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/).optional(),
Add \.\d{3} before the $ for milliseconds, or Z? for an optional designator. A pattern that is not a valid regular expression is rejected when the configuration is validated. Other formats are unaffected.
uuidFormat controls the validator for both format: uuid and format: guid. uuid (the default) produces z.uuid(), guid produces z.guid(), and uuidv1 to uuidv8 produce z.uuid({ version: "v4" }) and so on, which rejects identifiers of any other version.
Constraints
Every validation keyword the specification allows on a primitive or a container is carried into the schema:
| Keyword | Generated |
|---|---|
minLength, maxLength, pattern | .min(n), .max(n), .regex(/…/) |
minimum, maximum | .gte(n), .lte(n) |
exclusiveMinimum, exclusiveMaximum | .gt(n), .lt(n), for both the 3.0 boolean form and the 3.1 numeric form |
multipleOf | .multipleOf(n) |
type: integer | .int() |
minItems, maxItems | .min(n), .max(n) on the array |
uniqueItems: true | .refine(…) comparing the array to a Set of itself |
contains, minContains, maxContains | .refine(…) counting the items that satisfy the contains schema |
prefixItems (3.1) | z.tuple([…]), with .rest(…) when items is also set |
additionalProperties | .catchall(schema) or .catchall(z.unknown()); false closes the object |
minProperties, maxProperties | .refine(…) counting keys |
propertyNames, patternProperties | .catchall(…) plus a .superRefine(…) that checks each key |
const | z.literal(value) |
if / then / else | .superRefine(…) applying the then or else requirements according to the condition |
not | .refine(…) on the base type, rejecting any value the excluded schema accepts |
A few examples from a specification exercising these:
export const stringConstraintsSchema = z
.string()
.min(3)
.max(50)
.regex(/^[a-zA-Z]+$/);
export const coordinatesSchema = z.tuple([z.number().gte(-90).lte(90), z.number().gte(-180).lte(180)]);
export const tupleWithRestSchema = z.tuple([z.string(), z.number().int()]).rest(z.string());
export const minPropsOnlySchema = z
.strictObject({})
.catchall(z.unknown())
.refine(obj => Object.keys(obj).length >= 1, { message: "Object must have at least 1 property" });
An if condition is evaluated the way JSON Schema defines it: a type test uses Number.isInteger, Array.isArray or === null as appropriate, and a property that is absent satisfies the condition vacuously, so an object that omits the property named in if takes the then branch. A pattern that is not a valid JavaScript regular expression is reported and dropped rather than written into the file.
Nullable values
nullable: true (OpenAPI 3.0) and a type array containing "null" (OpenAPI 3.1) both produce .nullable(); a type array with several non-null members becomes a union first:
export const multipleTypesWithNullSchema = z.union([z.string(), z.number()]).nullable();
defaultNullable: true makes property values nullable unless the schema says nullable: false. It applies to primitive properties and to properties that are a $ref (add nullable: false to the property to keep a reference non-nullable), and does not apply to top-level schema definitions, to enums and constants, or to the result of an allOf, oneOf or anyOf. The concept page, request-and-response-shapes.md, explains when to turn it on.
Composition
allOf over object schemas becomes a chain of .extend() calls on the first member, so the result stays a Zod object that can itself be extended. Primitive members are combined with .and(). A single-member allOf is an alias.
export const userSchema = baseEntitySchema.extend({
name: z.string(),
email: z.email(),
});
export const productSchema = baseEntitySchema.extend(timestampedSchema.shape).extend({
name: z.string().optional(),
price: z.number().optional(),
});
export const shortNameSchema = baseNameSchema.and(z.string().min(3).max(8));
export const nullableUserSchema = userSchema.nullable();
When two allOf members declare the same property with different schemas, the later one wins and the conflict is recorded in a @warning JSDoc comment on the schema. Constraints declared next to a $ref inside an allOf are applied to the referenced schema.
oneOf and anyOf become z.union([…]). With a discriminator whose property is required in every member, including members that reach it through an allOf, they become z.discriminatedUnion("petType", [dogSchema, catSchema]), which validates faster and reports errors against the matching branch only. If the property is not required in some member, the generator falls back to z.union() and says so in a warning. A one-member oneOf or anyOf collapses to that member; an empty one becomes z.never() with a warning, since the specification is malformed.
export const dogSchema = z.strictObject({
petType: z.literal("dog"),
bark: z.boolean().optional(),
});
export const petSchema = z.discriminatedUnion("petType", [dogSchema, catSchema]);
Enums are generated according to their value types: strings become z.enum([...]), numbers and mixed values become z.union([z.literal(0), z.literal("none"), …]), and a boolean enum becomes z.boolean().
Defaults
A non-required property with a default is generated with .default(value) instead of .optional(). The key is still optional on input, but parsing an object without it returns the default:
WithDefaults:
type: object
required: [required_field]
properties:
qty:
type: integer
default: 10
label:
type: string
default: none
required_field:
type: string
export const withDefaultsSchema = z.strictObject({
qty: z.number().int().default(10),
label: z.string().default("none"),
required_field: z.string(),
});
withDefaultsSchema.parse({ required_field: "r" }) returns { required_field: "r", qty: 10, label: "none" }. The inferred TypeScript type is unchanged: qty stays optional on the input side.
Request and response shapes
readOnly properties are sent by the server and never by the client; writeOnly properties are the reverse. schemaType picks which side a file describes:
User:
type: object
properties:
id:
type: string
format: uuid
readOnly: true
username:
type: string
password:
type: string
writeOnly: true
schemaType | Generated userSchema |
|---|---|
all (default) | id, username, password |
request | username, password |
response | id, username |
The usual arrangement is two specs over one input, schemaType: "request" into requests.ts and schemaType: "response" into responses.ts. When outputZodSchemas is set the option is forwarded to the types file as well, so both halves describe the same shape. The option and its background are in request-and-response-shapes.md.
Circular references
A schema that refers to itself, or two schemas that refer to each other, cannot be declared in order, so the back reference is wrapped in z.lazy():
TreeNode:
type: object
properties:
value: { type: string }
left:
$ref: "#/components/schemas/TreeNode"
right:
$ref: "#/components/schemas/TreeNode"
export const treeNodeSchema = z.strictObject({
value: z.string().optional(),
left: z.lazy((): z.ZodTypeAny => treeNodeSchema).optional(),
right: z.lazy((): z.ZodTypeAny => treeNodeSchema).optional(),
});
export type TreeNode = z.infer<typeof treeNodeSchema>;
Only schemas that are actually on a cycle receive z.lazy(). In this combined form the inferred type loses precision inside the cycle (z.ZodTypeAny), which is why specifications with circular references are better generated with separate types and schemas, described next. The statistics block counts the schemas that contain z.lazy().
Separate types and schemas
Setting outputZodSchemas splits the output in two: outputTypes receives plain TypeScript types produced by openapi-to-typescript, and outputZodSchemas receives the Zod schemas, each checked against its type instead of inferring it.
export default defineConfig({
specs: [
{
input: "tree.yaml",
outputTypes: "src/generated/types.ts",
outputZodSchemas: "src/generated/schemas.ts",
},
],
});
// types.ts
export type TreeNode = {
value?: string;
left?: TreeNode;
right?: TreeNode;
};
// schemas.ts
import { z } from "zod";
import type { TreeNode } from "./types";
export const treeNodeSchema: z.ZodType<TreeNode> = z.strictObject({
value: z.string().optional(),
left: z.lazy((): z.ZodType<TreeNode> => treeNodeSchema).optional(),
right: z.lazy((): z.ZodType<TreeNode> => treeNodeSchema).optional(),
});
A schema that is not recursive is declared with satisfies z.ZodType<User>, which keeps the inferred Zod object (so .extend() and z.discriminatedUnion() keep working) while still reporting any drift between the schema and the type. A recursive schema needs the : z.ZodType<TreeNode> annotation, the only form that breaks the circular initialiser. Both files carry the same options, including schemaType, useOperationId, ignoreHeaders and operationFilters, so they always agree on which declarations exist.
This mode is also the answer to TypeScript's "Type instantiation is excessively deep and possibly infinite" error on very large specifications. If satisfies alone still triggers it, typeAssertionThreshold switches the largest schemas to a double assertion, as unknown as z.ZodType<User>, which bypasses the structural check entirely. Complexity is counted as properties plus ten per nesting level plus two per array or union member; a schema at or above the threshold uses the assertion, the rest keep satisfies. The default of 0 never asserts. A member of a discriminated union that crosses the threshold is declared with no type binding at all, because an asserted member would lose the literal types z.discriminatedUnion() needs.
// typeAssertionThreshold: 5
export const tupleWithRestSchema = z
.tuple([z.string(), z.number().int()])
.rest(z.string()) as unknown as z.ZodType<TupleWithRest>;
export const mixedTupleSchema = z.tuple([z.string(), z.number(), z.boolean()]) satisfies z.ZodType<MixedTuple>;
In this mode enumFormat decides how an enum schema is declared in the types file: const-object (the default) produces a constant object and a type, union produces a literal union. The Zod side is z.enum([...]) either way. The option has no effect without outputZodSchemas. The types file is described in detail in typescript.md.
Operation schemas
For every operation that declares query or header parameters, the generator adds a schema for them, named after the operation. The operation name is the operationId when there is one (useOperationId defaults to true), otherwise the method and path; see naming.md.
/**
* Query parameters for searchUsers
*/
export const searchUsersQueryParamsSchema = z.strictObject({
page: z.number().int().gte(1).optional(),
limit: z.number().int().gte(1).lte(100).optional(),
sort: z.enum(["name", "createdAt", "updatedAt"]).optional(),
active: z.boolean().optional(),
tags: z.array(z.string()).optional(),
});
export type SearchUsersQueryParams = z.infer<typeof searchUsersQueryParamsSchema>;
Query parameters keep their types and constraints and are required according to the parameter's required. Header parameters are always z.string().optional(), because an HTTP header is text and a service layer typically supplies some headers itself. Parameters declared on the path item and parameters reached through $ref are merged in. ignoreHeaders removes headers from these schemas by name or glob pattern, case-insensitively, which is how to leave Authorization or X-Trace-* out of a schema when the transport adds them; a pattern that matches no header produces a warning. These schemas use the request block's mode and useDescribe.
operationFilters restricts the operations that produce parameter schemas and, with them, the component schemas that are generated at all: a component that no surviving operation references, directly or transitively, is left out. Schemas on a reference cycle are always kept.
Statistics and warnings
With showStats at its default of true, the file starts with a statistics block. It lists the number of schemas, how many contain z.lazy(), how many are discriminated unions and how many carry .min(), .max() or .gte() constraints, the filter counts when operationFilters is set, and the generation time:
// Generation Statistics:
// Total schemas: 2
// Circular references: 0
// Discriminated unions: 0
// With constraints: 1
//
// Operation Filtering:
// Total operations: 4
// Included operations: 2
// Filtered operations: 2
// - By methods: 2
// Generated at: 2026-11-04T18:41:40.954Z
Because the timestamp changes on every run, set showStats: false if you commit the file and want stable diffs.
Warnings are collected and printed together at the end of the run, prefixed with the package name. They are raised for two schemas or operations whose names normalise to one identifier (the file declares both, and at run time the second shadows the first, so this one deserves attention), a discriminated union that had to fall back to z.union(), an empty oneOf or anyOf, an allOf whose members carry only constraints and no type, an invalid pattern, a format combined with a contentEncoding, and filters or ignoreHeaders patterns that matched nothing. showWarnings: false silences them.
Performance
Every pattern in the specification is escaped and validated once and then cached. cacheSize is the number of distinct patterns the cache holds, 1000 by default; raise it for very large specifications with many unique patterns. Each generator instance has its own cache, so specs generated in parallel do not interfere. batchSize limits how many specs run concurrently in parallel mode.
Programmatic API
import { OpenApiGenerator, defineConfig } from "@spartaficial/openapi-to-zod";
const generator = new OpenApiGenerator({
input: "simple.yaml",
outputTypes: "src/api/schemas.ts",
mode: "normal",
});
generator.generate(); // writes outputTypes (and outputZodSchemas when set)
const code = generator.generateString(); // the schemas file as a string, nothing written
OpenApiGenerator takes an OpenApiGeneratorOptions object, the same keys as one spec entry, and reads and validates the specification in its constructor. generate() writes the file or files and prints their paths; generateString() returns the schemas file (in separate mode, the Zod half); generateTypesString() returns the types half that separate mode would write. All of them print the collected warnings. The licence is checked lazily on first use, as described in licensing.md.
defineConfig(config) returns its argument and gives .ts configuration files type checking. This package does not export a configuration loader; drive the generator with your own options, or use the CLI for configuration files. PropertyGenerator and PropertyGeneratorContext, together with buildDateTimeValidation(pattern?) and buildUuidValidation(format?), are exported for generators that extend this one, which is how openapi-to-zod-playwright is built.
The error classes are re-exported from @spartaficial/openapi-core; all extend GeneratorError and carry code and context:
| Class | Thrown when |
|---|---|
ConfigurationError | input is missing. |
SpecValidationError | The file is empty or malformed, declares no components.schemas, or a $ref does not resolve. |
SchemaGenerationError | A schema could not be converted; schemaName says which. |
CircularReferenceError | A cycle could not be handled; referencePath lists it. |
FileOperationError | An output file could not be written; filePath says where. |
ConfigValidationError | A configuration file failed validation (CLI). |
CliOptionsError | Command-line options were inconsistent. |
Exported types: OpenApiGeneratorOptions, CommonSchemaOptions, RequestOptions, ResponseOptions, UuidFormat, ConfigFile, ExecutionMode, OperationFilters, and the specification types OpenAPISpec, OpenAPISchema, OpenAPIParameter, OpenAPIRequestBody and OpenAPIResponse. General-purpose utilities such as the LRU cache, name conversion and batch execution live in @spartaficial/openapi-core; see openapi-core.md.
Related
- getting-started.md: a first run from an empty project.
- options-zod.md: every option with type and default.
- cli.md: commands and flags.
- openapi-feature-support.md: the keyword-by-keyword support matrix.
- request-and-response-shapes.md:
schemaType,readOnly,writeOnlyanddefaultNullable. - naming.md: how schema and type names are derived.
- typescript.md: the types file used in separate mode.
- zod-playwright.md: a Playwright client that validates with these schemas.