Repository navigation
generate-client: writeOnly not modeled (all languages), mocks violate the description, generated code fails stricter type checks #3226
Description
Describe the bug
generate-client output is wrong or fails strict checks in three places.
The first one affects every target language (TypeScript, Python, Go, PHP), because it starts in the intermediate representation.
- A.
writeOnlyis not modeled. Write-only properties stay in response types and models in every language, so a response that follows the description can crash the Python and PHP clients. - B. The
mockgenerator produces data that breaks the description. Numeric bounds are ignored, write-only fields appear in responses, and the default list handlers return a cursor that never ends.
The result is that the generated mocks fail the generated Zod schemas for the same API. - C. The generated code does not pass stricter type checks. TypeScript
noUncheckedIndexedAccessandexactOptionalPropertyTypesfail, and so does Pythonmypy --strict.
The three parts are independent and can ship as separate pull requests.
Each part below has a reproduction, the source location, and acceptance criteria.
Redocly version(s)
@redocly/cli 2.62.0 (bundles @redocly/client-generator 0.5.1). The source references below are from main at fdffe75.
Node.js version(s)
Node 24.21.0, pnpm 10.17.1. TypeScript 5.9.3, zod 4.1.12, msw 2.11.6, Python 3.14 + httpx + mypy 2.4.0, PHP 8.5.
OS, environment
macOS 26 (Darwin 25.6).
Reproduction setup
mkdir gc-repro && cd gc-repro
npm init -y && npm pkg set type=module
npm i -D @redocly/cli@2.62.0 typescript@5.9.3 zod@4 msw@2 tsxusers.yaml (used by A and C):
openapi: 3.1.0
info: { title: Users, version: 1.0.0 }
servers: [{ url: https://api.example.com }]
paths:
/users:
post:
operationId: createUser
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
responses:
'201':
description: Created
content:
application/json:
schema: { $ref: '#/components/schemas/User' }
components:
schemas:
User:
type: object
required: [id, email, password]
properties:
id: { type: string, readOnly: true }
email: { type: string }
password: { type: string, writeOnly: true, minLength: 8 }
age: { type: integer, minimum: 18 }items.yaml (used by B):
openapi: 3.1.0
info: { title: Repro, version: 1.0.0 }
servers: [{ url: https://api.example.com }]
paths:
/items:
get:
operationId: listItems
responses:
'200':
description: OK
content:
application/json:
schema: { $ref: '#/components/schemas/ItemList' }
components:
schemas:
ItemList:
type: object
required: [items]
properties:
items: { type: array, items: { $ref: '#/components/schemas/Item' } }
Item:
oneOf:
- $ref: '#/components/schemas/Beverage'
- $ref: '#/components/schemas/Dessert'
Base:
type: object
required: [name]
properties:
name: { type: string }
photo: { writeOnly: true, type: [string, 'null'], format: binary }
Beverage:
allOf:
- type: object
required: [category, volume]
properties:
category: { type: string, const: beverage }
volume: { type: number, exclusiveMinimum: 0 }
- $ref: '#/components/schemas/Base'
Dessert:
allOf:
- type: object
required: [category, calories]
properties:
category: { type: string, const: dessert }
calories: { type: number, exclusiveMinimum: 0 }
- $ref: '#/components/schemas/Base'The in-repo fixture tests/e2e/generate-client/fixtures/cafe.yaml has the same shapes (writeOnly: true on photo, exclusiveMinimum: 0 on volume and calories), so it works as the regression fixture for A and B.
A. writeOnly is not modeled in the intermediate representation (all languages)
OpenAPI says a writeOnly property is sent in requests and never returned in responses.
The intermediate representation tracks readOnly (PropertyModel.readOnly in src/intermediate-representation/model.ts, and stripReadOnlyFromRequestBodies in src/intermediate-representation/build.ts) but has no writeOnly counterpart.
grep -rn writeOnly packages/client-generator/src finds nothing.
As a result, every generator treats a write-only property as part of the response.
To reproduce
for g in typescript python go php; do
npx redocly generate-client users.yaml --output out-$g/client.ts --generator $g
done
npx redocly generate-client users.yaml --output out-ts/client.ts --generator typescript --generator zod --generator mockThen decode a response that matches the description (password is write-only, so it is absent):
# Python (needs httpx)
cd out-python && python3 -c "import client; print(client.decode(client.User, {'id': 'u1', 'email': 'a@b.c'}))"
# TypeError: User.__init__() missing 1 required positional argument: 'password'
# PHP
cd out-php && php -r "require 'client.php'; var_dump(Users\User::fromArray(['id' => 'u1', 'email' => 'a@b.c']));"
# Warning: Undefined array key "password"
# TypeError: Users\User::__construct(): Argument #3 ($password) must be of type string, null givenActual, per language
| Target | What is generated | Effect on a spec-correct response |
|---|---|---|
| Python | password: str (required dataclass field); create_user returns decode(User, …) |
TypeError, the call fails |
| PHP | public string $password in the constructor; fromArray reads $data['password'] |
TypeError, the call fails |
| Go | Password string with the tag json:"password" (no omitempty, not a pointer) |
Decodes silently to "", and the type claims a value the API never returns |
| TypeScript | password: string in User, and the result type is User |
The type claims a value that is never there |
| zod | Response schema requires password: z.string().min(8) |
zodValidation() reports drift on every correct response (false positive) |
| mock | createUser() returns password: "string"; items.yaml mocks return photo: new Blob([]), which serializes to {} |
Mocks contradict the description and fail the zod schema |
Expected behavior
Write-only properties are handled symmetrically to how readOnly is handled today:
- Intermediate representation. Add
writeOnly?: booleantoPropertyModel, set it inbuild.tsnext toreadOnly, including the 3.0$ref-sibling handling aroundbuild.ts:1034.
Add the response-side equivalent ofstripReadOnlyFromRequestBodies: strip write-only properties from success response schemas, asOmit<Name, …writeOnly>for a named schema and filtered properties for an inline one. - TypeScript. Operation result types exclude write-only properties.
- zod. The response schemas used by
zodValidation()do not require or validate write-only properties. Request schemas keep them. - mock. Response factories and handlers never emit write-only properties.
- Python, Go, PHP. These generators share one model class per schema between requests and responses, and use the base type for request bodies.
For responses, a required write-only property must not make decoding fail.
The minimal fix is to make write-only properties optional in the model, as is already done for non-required ones:Optional[...] = Nonein Python,?type ... = nullwith$data[...] ?? nullin PHP, and a pointer withomitemptyin Go.
The Go change keeps request bodies valid and stops claiming a value in responses.
Use dedicated response types instead if that matches the per-language design better. Seesrc/generators/*/AGENTS.md.
Acceptance criteria
- New e2e tests (alongside
python.test.ts,php.test.ts,go.test.tsandzod.test.ts) that decode a response without the write-only property for every language, usingusers.yamlorfixtures/cafe.yaml. - The same tests check that a request body still carries the write-only property.
- Snapshot updates are limited to the write-only properties.
B. The mock generator produces data that breaks the description
All three problems are in src/generators/mock/ (TypeScript only today).
Any future mock generator for another language should share the same value synthesis.
B1. Numeric bounds are ignored.
scalarSampleinsrc/generators/mock/sample.ts(around line 293) returns0for everyintegerandnumber, whateverminimum,exclusiveMinimum,maximum,exclusiveMaximumormultipleOfsay.- In faker mode,
boundsArginsrc/generators/mock/faker.ts(around line 172) passesminimumandmaximumbut ignoresexclusiveMinimumandexclusiveMaximum. - With
users.yaml,createUser()returnsage: 0againstminimum: 18. - With
items.yaml,createBeverage()returnsvolume: 0andcreateDessert()returnscalories: 0againstexclusiveMinimum: 0.
B2. Write-only properties are emitted in response mocks. This is the mock side of A.
B3. The default paginated list handlers never terminate.
The default page sets the next cursor to a non-null placeholder (nextPage: "string"), and the handler returns the same page on every request.
With pagination configured (the Cafe convention is style: cursor, cursorParam: after, nextCursor: /page/nextPage, items: /items), .pages() against the default handlers throws on the second page:
Error: Pagination did not advance: operation returned the same cursor twice
To reproduce B1 and B2
npx redocly generate-client items.yaml --output src/client.ts --generator typescript --generator zod --generator mock// check.ts — run with: npx tsx check.ts
import { setupServer } from 'msw/node';
import { createClient, OPERATIONS, type Ops } from './src/client.ts';
import { handlers, createItemList } from './src/client.mocks.ts';
import { zodValidation, operationSchemas } from './src/client.zod.ts';
const direct = operationSchemas.listItems.response!.safeParse(createItemList());
console.log(direct.success, direct.success || direct.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`));
setupServer(...handlers).listen();
const client = createClient<Ops>(OPERATIONS, {
serverUrl: 'https://api.example.com',
middleware: [zodValidation({ onViolation: (e) => console.log(e.violations.map((v) => v.path)) })],
});
await client.listItems();Actual output
false [ 'items.0.volume: Too small: expected number to be >0' ]
[ 'items.0.volume', 'items.0.photo', 'items.0.photo', 'items.0.category', 'items.0.calories', 'items.0.photo', 'items.0.photo' ]
Expected behavior
The generated mocks satisfy the generated schemas for the same description.
- Numeric samples respect
minimum,exclusiveMinimum,maximum,exclusiveMaximumandmultipleOf, in static and faker modes.
A reasonable rule is to pick the smallest value that satisfies the bounds, for exampleminimum, orexclusiveMinimum + 1for integers and+ multipleOf-aligned values otherwise.
A specexampleordefaultthat satisfies the bounds still wins, as it does today. - Response mocks omit write-only properties.
- The default page of a paginated operation is the last page: the next cursor is
null(orhasMoreisfalse), so.pages()and.items()over the default handlers end after one page.
Acceptance criteria
- A property-style test over the e2e fixtures (at least
cafe.yaml,base.yamlandpagination.yaml): for every named schemaXthat has a zod schema and a factory,XSchema.safeParse(createX()).success === true.
For every operation, its default handler's response passesoperationSchemas[op].response. - A test that
.pages()over the default handlers of a paginated operation yields exactly one page. - Unit tests for the bound arithmetic in
sample.tsandfaker.ts, including integer and number, inclusive and exclusive bounds, andmultipleOf.
C. The generated code does not pass stricter type checks
The e2e bar strictTypecheck (tests/e2e/generate-client/helpers.ts, STRICT_TSCONFIG) uses strict: true only.
Many projects also enable noUncheckedIndexedAccess and exactOptionalPropertyTypes, and the generated client is compiled as part of their sources, so skipLibCheck does not help.
To reproduce (TypeScript)
Generate any client (here items.yaml with --generator typescript), then:
npx tsc --noEmit --strict --noUncheckedIndexedAccess --skipLibCheck --target es2022 --module esnext --moduleResolution bundler --lib es2022,dom src/client.tsActual
With noUncheckedIndexedAccess, the errors are in the embedded runtime, src/generators/typescript/runtime/:
paginate.ts:144 /;\s*rel\s*=.../i.exec(target[2]) error TS2345: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
paginate.ts:146 rel[1].split(/\s+/) error TS2532: Object is possibly 'undefined'.
send.ts:209 middleware[i].onResponse error TS2532: Object is possibly 'undefined'.
The paginate.ts errors appear only when the API uses pagination.
With exactOptionalPropertyTypes (and noUncheckedIndexedAccess off), there are 3 errors.
The first is in the middleware assembly in create-client.ts: an object literal { onRequest, onResponse, onError } with possibly undefined members is assigned to Middleware[].
Python, same class of problem.
mypy --strict client.py on the Python output for users.yaml reports 10 errors.
They include no-any-return (the decode(...) returns), arg-type (resolve_auth receives str | list[...]), no-untyped-def, and Cannot redefine "T" as a type variable.
The current Python bar is import client / py_compile, which does not run a type checker.
PHP. Not checked. The current bar is php -l plus require. PHPStan at a high level is the equivalent check.
Go. Already covered by go vet in the e2e bars.
Expected behavior
Generated code type-checks under the strictest common settings of each language, so users can compile it with their own settings.
Acceptance criteria
- Fix the runtime sources: use explicit guards instead of non-null assertions where possible, and conditional spreads for the middleware object.
- Add
noUncheckedIndexedAccess: trueandexactOptionalPropertyTypes: truetoSTRICT_TSCONFIG. - Add a
mypy --strict(orpyright --strict) step to the Python e2e bar. - Add a PHPStan step to the PHP bar, with the level decided by the maintainers. Each step is skipped when the tool is missing, like the existing
hasPythonandhasPhpgates inlarge-descriptions.test.ts.
Additional context
For B, the end-user workaround is to override the affected fields in every factory call, for example createBeverage({ volume: 250, photo: undefined }), and to pass createPage({ nextPage: null }) to list handlers.
For C, it is to turn off noUncheckedIndexedAccess for the package that compiles the generated client.
Both are easy to miss, and the B workaround has to be repeated in every test.
Activity