Skip to main content

Request and response shapes

What readOnly and writeOnly mean for generated types and schemas, how schemaType chooses which side of an exchange to generate in the TypeScript and zod generators, what the zod generator's request and response option blocks add, and why the Playwright and k6 generators have no schemaType at all. Read it before validating request bodies with generated schemas, or when a generated type lets you send a property the server rejects.

One schema, two shapes​

OpenAPI lets a property belong to one direction of an exchange. A property marked readOnly: true is sent by the server and never by you; one marked writeOnly: true is the reverse.

components:
schemas:
Account:
type: object
required:
- name
properties:
id:
type: string
format: uuid
readOnly: true
name:
type: string
password:
type: string
format: password
writeOnly: true

Account therefore describes two shapes: what you POST (name and password) and what you GET back (id and name). A type that keeps all three matches neither exactly. It is fine for reading, but it suggests you may send an id, and a schema built from it accepts an id in a request body that the server will reject.

schemaType​

schemaType picks the shape. It is an option of the TypeScript and zod generators, settable per entry or in defaults:

ValueDropsAccount becomes
"all" (default)nothing{ id?, name, password? }
"request"readOnly properties{ name, password? }
"response"writeOnly properties{ id?, name }

The filtering is recursive: nested objects, the items of arrays of objects and the members of allOf, oneOf and anyOf are filtered the same way, and a dropped property is also removed from required. Properties declared through a $ref are filtered where the referenced schema is generated.

The usual arrangement is to generate the same specification twice into separate files, with a suffix telling the two apart:

import { defineConfig } from "@spartaficial/openapi-to-typescript";

export default defineConfig({
specs: [
{ input: "openapi.yaml", outputTypes: "src/types-all.ts" },
{ input: "openapi.yaml", outputTypes: "src/types-request.ts", schemaType: "request", suffix: "Request" },
{ input: "openapi.yaml", outputTypes: "src/types-response.ts", schemaType: "response", suffix: "Response" },
],
});

The three files the TypeScript generator writes for the Account schema above:

// src/types-all.ts
export type Account = {
/** @format uuid */
id?: string;
name: string;
/** @format password */
password?: string;
};
// src/types-request.ts
export type AccountRequest = {
name: string;
/** @format password */
password?: string;
};
// src/types-response.ts
export type AccountResponse = {
/** @format uuid */
id?: string;
name: string;
};

The same configuration for @spartaficial/openapi-to-zod produces schemas that enforce the shape at run time. In the default strict mode an id in a request body now fails validation instead of passing:

// src/schemas-request.ts
export const accountRequestSchema = z.strictObject({
name: z.string(),
password: z.string().optional(),
});
export type Account = z.infer<typeof accountRequestSchema>;
// src/schemas-response.ts
export const accountResponseSchema = z.strictObject({
id: z.uuid().optional(),
name: z.string(),
});
export type Account = z.infer<typeof accountResponseSchema>;

The zod generator applies suffix to the schema constant and not to the inferred type (see naming.md), which is one more reason to keep the two directions in separate files. When the zod generator writes a separate types file through outputZodSchemas, the types file drops the same properties, so both halves of the pair describe the same shape.

schemaType is listed with its type and default in reference/options-typescript.md and reference/options-zod.md.

The zod generator's request and response blocks​

Dropping properties is one difference between the two directions; how strictly to validate is another. A test that validates what the API returned usually wants strict mode, so an undocumented property is caught. The same schema used to validate a body before sending it may be happier in loose mode. The zod generator's request and response option blocks set mode, useDescribe and includeDescriptions per direction, each falling back to the top-level option when not set. The blocks also accept defaultNullable and emptyObjectBehavior, but those two are read from the top level only; setting them inside a block changes nothing.

export default defineConfig({
specs: [
{
input: "openapi.yaml",
outputTypes: "src/schemas.ts",
request: { mode: "strict" },
response: { mode: "loose", includeDescriptions: false },
},
],
});

Which block a schema takes is decided by where the specification uses it. A schema reached only from request bodies and parameters is a request schema; one reached only from responses is a response schema. A schema used in both directions, a schema that is part of a reference cycle, and a schema no operation references at all take the request block. In the example this means that a schema returned by the API and never sent is generated as z.looseObject(...) without JSDoc, while everything else stays strict. The blocks do not change which properties are generated; that remains schemaType's job, and it applies to all schemas in the file.

The Playwright generator accepts the same two blocks and forwards them to the schemas it generates. Both are listed in reference/options-zod.md and reference/options-zod-playwright.md.

defaultNullable​

Whether a property may be null is the other half of its shape. OpenAPI 3.0 makes a property nullable only when it says nullable: true, and 3.1 only when its type list includes "null". Specifications generated from languages whose reference types are null by default often omit the annotation and mean nullable anyway; validating their responses in strict mode then fails on every null. defaultNullable: true treats every property value that carries no annotation as nullable. It is accepted by all four generators, per entry or in defaults.

components:
schemas:
Address:
type: object
properties:
street:
type: string
Kind:
type: string
enum: [person, company]
Customer:
type: object
required: [name]
properties:
name:
type: string
nickname:
type: string
address:
$ref: "#/components/schemas/Address"
kind:
$ref: "#/components/schemas/Kind"
tier:
type: string
enum: [gold, silver]
version:
type: integer
const: 2
reference:
type: string
nullable: false
billing:
allOf:
- $ref: "#/components/schemas/Address"
- type: object
properties:
vat:
type: string

The zod generator with defaultNullable: true writes:

export const addressSchema = z.strictObject({
street: z.string().nullable().optional(),
});

export const kindSchema = z.enum(["person", "company"]);

export const customerSchema = z.strictObject({
name: z.string().nullable(),
nickname: z.string().nullable().optional(),
address: addressSchema.nullable().optional(),
kind: kindSchema.nullable().optional(),
tier: z.enum(["gold", "silver"]).optional(),
version: z.literal(2).optional(),
reference: z.string().optional(),
billing: addressSchema
.extend({
vat: z.string().nullable().optional(),
})
.optional(),
});

The option applies to property values inside objects, whatever they are: primitives (nickname), required primitives (name stays required but accepts null), properties that reference another schema (address, kind) and the properties of nested objects (vat). It does not apply to the schema definitions themselves (kindSchema is not nullable, only the kind property that uses it), to inline enums (tier), to constants (version), or to the result of an allOf, oneOf or anyOf (billing), because those define a shape rather than hold a value. A single property opts out with an explicit nullable: false (reference). Note that a referenced schema does become nullable at the point of use, so to keep a reference non-nullable under defaultNullable: true the property that holds it needs nullable: false.

The TypeScript generator honours the same option for the types it emits, but goes further than the zod generator in one respect: it also adds | null to inline enums, constants and the exported enum types, so for those constructs the two generators' outputs do not agree exactly. The option is listed in each generator's reference page, for example reference/options-zod.md.

Why Playwright and k6 have no schemaType​

The Playwright and k6 generators do not accept schemaType; setting it is rejected as an unknown key, so a configuration cannot ask for something these generators would silently ignore.

A k6 client sends and receives. Its service methods use one type for the request body they send and the parsed response they return, so one global direction has no coherent meaning for the file as a whole.

The Playwright generator already chooses the direction where it matters. A request body declared inline in an operation is generated as a request schema (readOnly properties dropped), and a response declared inline is generated as a response schema (writeOnly properties dropped), so the service validates each side of a call against the shape that side actually has. Named component schemas, which a specification may use in both directions, keep every property. A global schemaType would override that per-schema choice, which is why the option does not exist there.

Both generators therefore pick the right shape for you where they can, and keep the whole schema where the specification does not say. If you need a request-only or response-only variant of a component schema for your own assertions, generate it with @spartaficial/openapi-to-zod into a separate file as shown above; its output has no dependency on the Playwright client and can be used next to it.