Skip to content

Feature add typescript express zod server and client#23132

Open
istvanfedak wants to merge 15 commits into
OpenAPITools:masterfrom
istvanfedak:feature-add-typescript-express-zod-server-and-client
Open

Feature add typescript express zod server and client#23132
istvanfedak wants to merge 15 commits into
OpenAPITools:masterfrom
istvanfedak:feature-add-typescript-express-zod-server-and-client

Conversation

@istvanfedak

@istvanfedak istvanfedak commented Mar 5, 2026

Copy link
Copy Markdown

[typescript-express-zod] Add server and client generators for Express + Zod

Stability: EXPERIMENTAL

This PR adds two new generators — typescript-express-zod-server and typescript-express-zod-client — that produce a fully typed Express v5 server and a matching HTTP client, both validated at runtime with Zod. This is an early prototype and I'm opening this PR primarily to gather feedback on the approach and generated output. The generators are used in development in a personal project and should be considered experimental.

PR checklist

  • Read the contribution guidelines.
  • Pull Request title clearly describes the work in the form [community] [typescript-express-zod] Add server and client generators
  • Filed the PR against the correct branch: master
  • Ran ./bin/generate-samples.sh ./bin/configs/typescript-express-zod-* to update sample output

Description

These generators target teams building Express APIs who want their OpenAPI spec to be the single source of truth for:

  • TypeScript types — domain models, service interfaces, parameter types, and response types
  • Zod schemas — runtime validation of every request and response against the spec
  • DTO layer — separate over-the-wire representations with bidirectional mappers
  • Router factory — a single getRouter() function that wires routes, middleware, validation, Swagger UI, and error handling
  • HTTP client — typed service classes that call the API, validate responses with Zod, and map DTOs to domain types

Generated code has zero hand-written boilerplate. You implement service interfaces; the generator handles routing, validation, serialization, and error responses.

Key features

Feature Details
Express v5 Targets Express 5 with native async handler support
Zod validation Request parameters validated on the way in, responses validated on the way out
DTO / mapper layer Separates wire format from domain types; compact() strips undefined fields
Router factory getRouter() accepts service factories, middleware config, and handler overrides
Swagger UI Auto-served at / from the embedded OpenAPI schema — no separate docs server needed
Typed middleware Per-endpoint middleware types with _onlySwaggerUI and _exceptSwaggerUI scopes
Service-per-tag Each OpenAPI tag becomes a service interface (e.g. AuthService, OrganizationsService)
Matching client typescript-express-zod-client generates HttpAuthService, HttpOrganizationsService classes that share the same interfaces
SSE streaming Built-in Server-Sent Events support for streaming endpoints
Structured errors ValidationErrorsError, HandledExceptionError, UnhandledExceptionError with type guards

Usage

  1. Generate the server
openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-express-zod-server \
  -o generated-server/
  1. Generated output structure
generated-server/
├── index.ts                 # Barrel export
├── types.ts                 # Service interfaces, domain types, param/response types
├── schemas.ts               # Zod schemas for every model, request, and response
├── dtos/
│   ├── types.ts             # Over-the-wire DTO representations
│   ├── mappers.ts           # Bidirectional DTO ↔ domain mappers
│   └── index.ts
├── express/
│   ├── router-factory.ts    # getRouter() — the main entry point
│   ├── handlers.ts          # Request handler factories (one per operation)
│   ├── types.ts             # RouterFactoryInput, Middleware, typed RequestHandlers
│   ├── errors.ts            # Structured error types with type guards
│   └── index.ts
└── package.json
  1. Implement the service contracts

The generator produces one interface per OpenAPI tag. You implement them:

import type { AuthService, OrganizationsService } from "./generated-server";

class MyAuthService implements AuthService {
  async getAuthDetails(params?: GetAuthDetailsParams) {
    // Your implementation — return { data: AuthDetailsData }
  }

  async login(params: LoginParams) {
    // Your implementation — return { data: LoginData }
  }
}

class MyOrganizationsService implements OrganizationsService {
  async createOrganization(params: CreateOrganizationParams) {
    // Return { data: Organization }
  }

  async listOrganizations() {
    // Return { data: Organization[] }
  }

  // ... other methods: deleteOrganization, getOrganization,
  //     inviteMember, listOrganizationMembers, removeMember,
  //     updateMemberRole, updateOrganization
}
  1. Wire it into Express via the router factory
import express from "express";
import { getRouter } from "./generated-server/express";
import schema from "./openapi.json";

const app = express();
app.use(express.json());

const apiRouter = getRouter({
  schema,
  // Service factories receive (req, res) — enabling request-scoped DI
  getAuthService: (req, res) => new MyAuthService(/* inject deps */),
  getOrganizationsService: (req, res) => new MyOrganizationsService(/* inject deps */),
  // Optional: per-endpoint middleware
  middleware: {
    _exceptSwaggerUI: authMiddleware,  // Protect all API routes
    _onlySwaggerUI: corsMiddleware,    // Only on Swagger UI
  },
  // Optional: override specific handlers entirely
  handlerOverrides: {
    login: customLoginHandler,
  },
});

app.use("/api", apiRouter);
app.listen(3000);
// Swagger UI now available at http://localhost:3000/api/
  1. Generate the matching client
openapi-generator-cli generate \
 -i openapi.yaml \
 -g typescript-express-zod-client \
 -o generated-client/
import { HttpAuthService, HttpOrganizationsService } from "./generated-client";

// Bring your own fetch — the client accepts any FetchLike implementation
const authClient = new HttpAuthService(fetch, { root: "https://api.example.com" });
const orgClient = new HttpOrganizationsService(fetch, { root: "https://api.example.com" });

const result = await authClient.login({
  loginRequest: { email: "user@example.com", password: "secret" },
});
// result is fully typed as Login200Response, validated with Zod

How the request/response pipeline works

Request
→ Express route matched by router-factory.ts
→ Middleware chain (global + per-endpoint)
→ Handler extracts params from req.body / req.params
→ DTO mapper converts wire format → domain types
→ Zod schema validates the params
→ Service method executes your business logic
→ Error check: if result contains { errors: [...] }, send error response
→ DTO mapper converts domain response → wire format
→ res.status(200).json(responseDto)
→ Post-send: Zod validates response against schema (catches contract drift)

Key design decisions

Why Zod instead of class-validator / class-transformer?
Zod schemas are generated directly from the OpenAPI spec and validate both request and response payloads at runtime. After sending the response, the handler re-validates it against the schema — this catches contract drift between your implementation and the spec early (as a 500-level validation error if headers were already sent, or a 400 if not).

Why a separate DTO layer?
The DTO types represent the exact wire format (e.g. dates as strings). Domain types may diverge as the spec evolves. The compact() utility in mappers strips undefined values to produce clean JSON payloads. Having both layers lets you evolve the internal representation without breaking the API contract.

Why service factory functions (req, res) => Service instead of static instances?
This enables request-scoped dependency injection. Each request gets a fresh service instance with access to the current request context (auth headers, tenant ID, etc.) without requiring a DI framework.

Why Express v5?
Express 5 supports async route handlers natively and has improved path matching. The generated handlers are all async and rely on Express 5's promise-based error propagation.

Why OPTIONS handlers and 405 Method Not Allowed?
Every route explicitly declares which HTTP methods it supports. Unsupported methods return 405 with an Allow header — proper REST semantics out of the box.


Summary by cubic

Adds a new server generator typescript-express-zod-server and migrates the client to the unified typescript generator via framework: express-zod. It produces a typed Express v5 server and matching client with runtime zod v4 validation and SSE support. Samples are regenerated and CI runs on Node 20/22.

  • New Features

    • Server: typescript-express-zod-server with typed router factory, middleware hooks, SSE streaming, and Swagger UI.
    • Client: use typescript with framework: express-zod to generate a matching HTTP client with DTO mappers and streaming helpers.
    • Schemas: zod v4 for request/response validation; enums from allowable values; discriminator properties required.
    • Docs/registration: server generator added to META-INF/services; new docs page; index exports included.
    • CI: added samples and Jest e2e tests for both server and client with a GitHub Actions workflow (Node 20/22).
    • Samples: regenerated server/client outputs and configs to the unified generator and relocated spec path.
  • Bug Fixes

    • Shapes/mappers: recursive array/map resolution; correct free‑form object passthrough; nullable item support and null guards; discriminated unions throw ZodError on unknown discriminators (server input) and are import‑gated.
    • Types/schemas: correct path param dataType; optional request bodies honored; primitive body fallbacks use standard Zod primitives; fallbacks for models without properties.
    • Runtime correctness: validate response DTOs before sending via a shared sendResponse(); client skips JSON on no‑content; stronger error type guards; escape < in embedded Swagger UI schema.
    • SSE: fixed type casting and headers; server detects promise‑wrapped async iterables; client parser handles CRLF/multi‑line; streaming method mapping corrected.
    • Client/server alignment: guarded body mapping to avoid TypeErrors and let Zod produce 400s; server/client buildZodType fallbacks aligned.

Written for commit f8bafb1. Summary will update on new commits.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

23 issues found across 31 files


Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@@ -0,0 +1,6 @@
generatorName: typescript-express-zod
outputDir: samples/server/petstore/typescript/express/zod

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please setup a new GitHub workflow similar to https://github.com/OpenAPITools/openapi-generator/blob/master/.github/workflows/samples-typescript-nestjs-server.yaml for this output folder so that future changes will be tested by the CI automatically?

- Updated Zod to v4
- Update enum handling in Zod schemas to support allowable values.
- Make discriminator properties required in Zod schemas for better type inference.
- Refactor response headers in handlers for improved SSE support.
- Change merge to extend for intersection parents in schemas.
- Ensure dataType is correctly formatted in generated types.
@istvanfedak
istvanfedak force-pushed the feature-add-typescript-express-zod-server-and-client branch from 9f7c6fc to 9625d8d Compare July 5, 2026 15:28
…generators

Fixes for correctness/robustness issues raised in review of the
typescript-express-zod-server and typescript-express-zod-client generators:

- DTO mappers: guard referenced properties with `== null` (preserve null,
  never pass null into a mapper) for arrays and single refs, both directions.
- Discriminated-union mappers: add a default case that throws instead of
  falling through to an undefined return.
- Nullable handling: emit `T | null` in domain/DTO types and `.nullable()` in
  Zod schemas when a property is nullable.
- Path parameters: use the schema-derived dataType instead of hardcoded string.
- Request body: honor optionality (`?`) and use the correct (unescaped) type.
- Models without properties: emit fallback Zod schema and DTO type alias.
- Server handlers: validate the mapped wire payload (responseDto) against the
  response schema rather than the pre-mapped domain object.
- SSE: client parser handles CRLF framing and multi-line data; server detects
  promise-wrapped async iterables.
- Client: stop parsing JSON on void/no-content (e.g. 204) responses.
- Swagger UI: escape `<` in the embedded schema to prevent script breakout.
- Error type guards: guard against null/non-object before reading `.code`.
- Router types: dedicated HandlerOverrides type without the Swagger-UI scopes.
- Implement the previously failing server model unit test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 16 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

…andling

Two follow-up fixes from the automated review of the previous commit:

- Discriminated-union mappers: `mapFrom*Dto` (wire -> domain, request path) now
  throws a ZodError on an unknown discriminator so the handler maps it to a 4xx
  client error instead of a 500. `mapTo*Dto` (domain -> wire, server output) keeps
  throwing a plain Error (5xx), which is correct for a server-produced value.
- Server handlers validate the response payload BEFORE sending it; a schema
  mismatch is a server contract violation, so it now fails with a 5xx via
  next(unhandledException) without shipping invalid data to the client (previously
  the post-send validation could not prevent the bad payload from being sent).

The ZodError import in the DTO mappers is emitted only when a discriminated union
is present (new hasDiscriminatedUnion template flag), avoiding an unused import
under `noUnusedLocals`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 5 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

…edback

Follow-up fixes from the automated review of the previous commit:

- Client mappers: an unknown discriminator in mapFrom*Dto processes a server
  response, not client input, so it now throws a plain Error (unexpected server
  contract drift) instead of a ZodError. The client no longer imports ZodError
  (and the client codegen drops the now-unused hasDiscriminatedUnion flag). The
  server mapFrom*Dto keeps throwing ZodError, since there it maps client input
  and should surface as a 4xx.
- Server handlers: extract the duplicated response map/validate/send logic (SSE
  non-streaming and non-SSE branches) into a single sendResponse() helper,
  removing the risk of the two copies drifting apart.
- Response validation failures now go through errors.validationErrors(500, issues)
  instead of unhandledException, preserving the structured ZodIssue[] details and
  staying consistent with request validation, while keeping the 5xx status for a
  server-side contract violation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

istvanfedak and others added 2 commits July 5, 2026 16:53
…rror path

The client's mapFrom*Dto maps server responses. Throwing a generic Error for an
unknown discriminator bypassed the http-client's `instanceof ZodError` branch
(mapZodError), dropping structured issue details and any user-provided mapZodError
override, and funnelling the failure into mapUnhandledException.

Restore throwing a ZodError so the failure flows through the client's existing
response-validation error channel, but frame it correctly as unexpected
server-response contract drift (not a client input error). The ZodError import is
re-gated on the hasDiscriminatedUnion flag so specs without discriminated unions
don't get an unused import under noUnusedLocals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ent generators

Adds a GitHub Actions workflow (modeled on samples-typescript-nestjs-server) that
runs the generated express-zod output through a real runtime test suite on every
PR, so template/codegen regressions are caught automatically.

- bin/configs: replace the broken typescript-express-zod-petstore-new.yaml
  (invalid generatorName / nonexistent templateDir) with proper server and client
  configs.
- Add a small input spec (typescript-express-zod.yaml) that stays within the
  generators' currently supported surface (single-model request bodies and
  responses) while exercising path/query params, nested & nullable $ref
  properties, an enum ref, a void operation, and a discriminated union.
- samples/server/.../typescript-express-zod-server: hand-written service
  implementations + Express app wired via getRouter(), and a supertest e2e suite.
  npm test = tsc --noEmit (all generated files, strict + noUnusedLocals) + jest.
- samples/client/.../typescript-express-zod-client: a jest suite that drives the
  generated client with an injected fetch, covering parse -> Zod validation -> DTO
  mapping, the error path, a custom mapZodError override, and a discriminated union.
- .github/workflows/samples-typescript-express-zod.yaml: matrix of both samples x
  node 20/22, npm i + npm run test.

Also fixes the client http-client template: the void/no-content branch left the
fetch response unused, which fails under noUnusedLocals (surfaced by the sample's
void DELETE operation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 45 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="samples/server/petstore/typescript-express-zod-server/builds/default/express/router-factory.ts">

<violation number="1" location="samples/server/petstore/typescript-express-zod-server/builds/default/express/router-factory.ts:138">
P2: The Swagger UI `requestInterceptor` builds request URLs with two fragile patterns: `new URL(request.url)` will throw if Swagger emits a relative request URL (common when the OpenAPI spec uses relative server URLs), because the `URL` constructor requires a `base` parameter for relative inputs. Additionally, `currentUrl.href` preserves any hash fragment from the current page (e.g. `#/operationName`), so string-concatenating the API path onto it places the path after the hash, producing an invalid URL. Both cases break the 'Try it out' requests in the generated docs. The interceptor should use `new URL(request.url, window.location.href)` and construct the final URL via the `URL` constructor rather than string concatenation.</violation>
</file>

<file name="samples/client/petstore/typescript-express-zod-client/tsconfig.json">

<violation number="1" location="samples/client/petstore/typescript-express-zod-client/tsconfig.json:7">
P2: This `tsconfig.json` mixes production and test compilation in a single configuration file. Including both `builds/**/*.ts` and `test/**/*.ts` alongside `types: ["node", "jest"]` makes Jest globals globally available during type-checking of production code. This can mask accidental use of `describe`, `it`, or `expect` in generated client source files. Additionally, `outDir` is set without `noEmit`, so the configuration structurally targets emission of both source and test files into `dist`. Existing samples in this repository (e.g., `typescript-axios`) already use separate tsconfigs for builds and tests; consider splitting this into a root build tsconfig and a test-specific tsconfig, or at least moving `jest` to a test-only tsconfig and adding `noEmit: true` here.</violation>
</file>

<file name="samples/server/petstore/typescript-express-zod-server/builds/default/schemas.ts">

<violation number="1" location="samples/server/petstore/typescript-express-zod-server/builds/default/schemas.ts:43">
P2: Using `z.coerce.number().int()` for request-body integer fields can silently corrupt invalid payloads: `null` and empty strings are coerced to `0` by JavaScript `Number()`. For example, sending `{"authorId": null}` to the create-book endpoint yields `authorId: 0` instead of a validation error, because the field is optional but not nullable. Path and query params legitimately need coercion from strings, but body fields should reject `null` when the schema is not marked nullable.</violation>

<violation number="2" location="samples/server/petstore/typescript-express-zod-server/builds/default/schemas.ts:69">
P1: Boolean query parameters received from Express are strings (e.g. `"true"`), but the generated schema uses `z.boolean().optional()` without coercion. A request like `GET /books/1?expand=true` will fail schema validation because `"true"` is not a boolean primitive. Numeric params use `z.coerce.number()` for string values; boolean params should follow the same pattern or use an equivalent preprocessing/coercion strategy.</violation>
</file>

<file name="samples/client/petstore/typescript-express-zod-client/builds/default/http-client.ts">

<violation number="1" location="samples/client/petstore/typescript-express-zod-client/builds/default/http-client.ts:134">
P1: `deleteBook` silently treats HTTP error responses as success because it never checks `response.status`. Unlike the other methods, it does not call `response.json()` (which would at least throw on an unexpected body), so a 404 or 500 from the server resolves to a successful `Promise<void>`. The caller may assume a resource was deleted when it was not.</violation>
</file>

<file name="samples/server/petstore/typescript-express-zod-server/builds/default/express/handlers.ts">

<violation number="1" location="samples/server/petstore/typescript-express-zod-server/builds/default/express/handlers.ts:292">
P1: The `getHttpStatus` helper defaults to the success code (200) whenever an error item lacks a valid `status` field, but the `ModelError` type does not actually include `status` at all. This means every handled service error will be emitted with HTTP 200, causing clients to treat failures as successes. Consider adding `status` to `ModelError` (and its schema/DTO) so the reducer can compute a meaningful status, or at least default to a 4xx/5xx code when `status` is missing instead of the success code.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic


export const GetBookParamsSchema = z.object({
bookId: z.coerce.number().int(),
expand: z.boolean().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Boolean query parameters received from Express are strings (e.g. "true"), but the generated schema uses z.boolean().optional() without coercion. A request like GET /books/1?expand=true will fail schema validation because "true" is not a boolean primitive. Numeric params use z.coerce.number() for string values; boolean params should follow the same pattern or use an equivalent preprocessing/coercion strategy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/petstore/typescript-express-zod-server/builds/default/schemas.ts, line 69:

<comment>Boolean query parameters received from Express are strings (e.g. `"true"`), but the generated schema uses `z.boolean().optional()` without coercion. A request like `GET /books/1?expand=true` will fail schema validation because `"true"` is not a boolean primitive. Numeric params use `z.coerce.number()` for string values; boolean params should follow the same pattern or use an equivalent preprocessing/coercion strategy.</comment>

<file context>
@@ -0,0 +1,75 @@
+
+export const GetBookParamsSchema = z.object({
+  bookId: z.coerce.number().int(),
+  expand: z.boolean().optional(),
+});
+
</file context>

try {
const response = await this.fetch<unknown>(url, { method: "DELETE" });
// No response body expected (e.g. 204 No Content); nothing to parse.
void response;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: deleteBook silently treats HTTP error responses as success because it never checks response.status. Unlike the other methods, it does not call response.json() (which would at least throw on an unexpected body), so a 404 or 500 from the server resolves to a successful Promise<void>. The caller may assume a resource was deleted when it was not.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/typescript-express-zod-client/builds/default/http-client.ts, line 134:

<comment>`deleteBook` silently treats HTTP error responses as success because it never checks `response.status`. Unlike the other methods, it does not call `response.json()` (which would at least throw on an unexpected body), so a 404 or 500 from the server resolves to a successful `Promise<void>`. The caller may assume a resource was deleted when it was not.</comment>

<file context>
@@ -0,0 +1,178 @@
+    try {
+      const response = await this.fetch<unknown>(url, { method: "DELETE" });
+      // No response body expected (e.g. 204 No Content); nothing to parse.
+      void response;
+      return;
+    } catch (err) {
</file context>

(Array.isArray(candidate.data) && candidate.data.length === 0);
}

function getHttpStatus(success: number, result: unknown): number {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The getHttpStatus helper defaults to the success code (200) whenever an error item lacks a valid status field, but the ModelError type does not actually include status at all. This means every handled service error will be emitted with HTTP 200, causing clients to treat failures as successes. Consider adding status to ModelError (and its schema/DTO) so the reducer can compute a meaningful status, or at least default to a 4xx/5xx code when status is missing instead of the success code.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/petstore/typescript-express-zod-server/builds/default/express/handlers.ts, line 292:

<comment>The `getHttpStatus` helper defaults to the success code (200) whenever an error item lacks a valid `status` field, but the `ModelError` type does not actually include `status` at all. This means every handled service error will be emitted with HTTP 200, causing clients to treat failures as successes. Consider adding `status` to `ModelError` (and its schema/DTO) so the reducer can compute a meaningful status, or at least default to a 4xx/5xx code when `status` is missing instead of the success code.</comment>

<file context>
@@ -0,0 +1,300 @@
+    (Array.isArray(candidate.data) && candidate.data.length === 0);
+}
+
+function getHttpStatus(success: number, result: unknown): number {
+  if (!isError(result)) return success;
+  return result.errors.reduce((max, item) => {
</file context>

requestInterceptor: (request) => {
const currentUrl = new URL(window.location.href);
const requestUrl = new URL(request.url);
const newUrl = currentUrl.href + requestUrl.href.substring(requestUrl.origin.length)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Swagger UI requestInterceptor builds request URLs with two fragile patterns: new URL(request.url) will throw if Swagger emits a relative request URL (common when the OpenAPI spec uses relative server URLs), because the URL constructor requires a base parameter for relative inputs. Additionally, currentUrl.href preserves any hash fragment from the current page (e.g. #/operationName), so string-concatenating the API path onto it places the path after the hash, producing an invalid URL. Both cases break the 'Try it out' requests in the generated docs. The interceptor should use new URL(request.url, window.location.href) and construct the final URL via the URL constructor rather than string concatenation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/petstore/typescript-express-zod-server/builds/default/express/router-factory.ts, line 138:

<comment>The Swagger UI `requestInterceptor` builds request URLs with two fragile patterns: `new URL(request.url)` will throw if Swagger emits a relative request URL (common when the OpenAPI spec uses relative server URLs), because the `URL` constructor requires a `base` parameter for relative inputs. Additionally, `currentUrl.href` preserves any hash fragment from the current page (e.g. `#/operationName`), so string-concatenating the API path onto it places the path after the hash, producing an invalid URL. Both cases break the 'Try it out' requests in the generated docs. The interceptor should use `new URL(request.url, window.location.href)` and construct the final URL via the `URL` constructor rather than string concatenation.</comment>

<file context>
@@ -0,0 +1,199 @@
+              requestInterceptor: (request) => {
+                const currentUrl = new URL(window.location.href);
+                const requestUrl = new URL(request.url);
+                const newUrl = currentUrl.href + requestUrl.href.substring(requestUrl.origin.length)
+                request.url = newUrl;
+                return request;
</file context>

"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2022"],
"types": ["node", "jest"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This tsconfig.json mixes production and test compilation in a single configuration file. Including both builds/**/*.ts and test/**/*.ts alongside types: ["node", "jest"] makes Jest globals globally available during type-checking of production code. This can mask accidental use of describe, it, or expect in generated client source files. Additionally, outDir is set without noEmit, so the configuration structurally targets emission of both source and test files into dist. Existing samples in this repository (e.g., typescript-axios) already use separate tsconfigs for builds and tests; consider splitting this into a root build tsconfig and a test-specific tsconfig, or at least moving jest to a test-only tsconfig and adding noEmit: true here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/client/petstore/typescript-express-zod-client/tsconfig.json, line 7:

<comment>This `tsconfig.json` mixes production and test compilation in a single configuration file. Including both `builds/**/*.ts` and `test/**/*.ts` alongside `types: ["node", "jest"]` makes Jest globals globally available during type-checking of production code. This can mask accidental use of `describe`, `it`, or `expect` in generated client source files. Additionally, `outDir` is set without `noEmit`, so the configuration structurally targets emission of both source and test files into `dist`. Existing samples in this repository (e.g., `typescript-axios`) already use separate tsconfigs for builds and tests; consider splitting this into a root build tsconfig and a test-specific tsconfig, or at least moving `jest` to a test-only tsconfig and adding `noEmit: true` here.</comment>

<file context>
@@ -0,0 +1,20 @@
+    "module": "commonjs",
+    "moduleResolution": "node",
+    "lib": ["ES2022"],
+    "types": ["node", "jest"],
+    "strict": true,
+    "noUnusedLocals": true,
</file context>

export const CreateBookRequestSchema = z.object({
title: z.string(),
genre: GenreSchema.optional(),
authorId: z.coerce.number().int().optional(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using z.coerce.number().int() for request-body integer fields can silently corrupt invalid payloads: null and empty strings are coerced to 0 by JavaScript Number(). For example, sending {"authorId": null} to the create-book endpoint yields authorId: 0 instead of a validation error, because the field is optional but not nullable. Path and query params legitimately need coercion from strings, but body fields should reject null when the schema is not marked nullable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At samples/server/petstore/typescript-express-zod-server/builds/default/schemas.ts, line 43:

<comment>Using `z.coerce.number().int()` for request-body integer fields can silently corrupt invalid payloads: `null` and empty strings are coerced to `0` by JavaScript `Number()`. For example, sending `{"authorId": null}` to the create-book endpoint yields `authorId: 0` instead of a validation error, because the field is optional but not nullable. Path and query params legitimately need coercion from strings, but body fields should reject `null` when the schema is not marked nullable.</comment>

<file context>
@@ -0,0 +1,75 @@
+export const CreateBookRequestSchema = z.object({
+  title: z.string(),
+  genre: GenreSchema.optional(),
+  authorId: z.coerce.number().int().optional(),
+});
+
</file context>
Suggested change
authorId: z.coerce.number().int().optional(),
authorId: z.number().int().optional(),

…uards, and SSE type rendering

Addresses codegen discrepancies across the TypeScript-Express-Zod generator, server route templates, and client SDK output (Findings 1-4, 6-8, 10).

TypescriptExpressZodSupport.java:
- Fix Enum-$ref & free-form classification: Ensure leaves check `isEnum || isEnumRef`. Fall back to identity for free-form objects instead of generating fabricated Dto types/mappers (e.g., `dtos.StatusDto`).
- Implement recursive shape resolution: Introduce depth-suffixed lambdas for `shapeOf`, `arrayShape`, and `mapShape`. Nested arrays (e.g., `Array<Array<Item>>`) now structurally align across DTO arrays, Zod schemas, and runtime mappers.
- Support nullable items: Properly emit `.nullable()` schemas alongside null-skipping element mappers.
- Refactor `resolve()` signature to eliminate the unreachable 9-parameter variant, degrading to identity instead of fabricating an arbitrary DTO name when `rp == null`.

Server Templates:
- Guard body `mapFn` execution: Wrap body mappers in type-guards (e.g., checking `Array.isArray(b)`) to ensure malformed or absent bodies correctly fall through to Zod validation (400) instead of throwing runtime TypeErrors (500).
- Fix SSE type casting: Render explicit `Promise<{{{responseDomainGeneric}}}>` wrappers for SSE branches returning JSON array 200 responses, cleaning up unused generic bindings.
- Update `buildZodType` body fallback to emit standard Zod primitives (`z.string()`, `z.number()`, `z.boolean()`) for dereferenced-primitive bodies.

Client SDK:
- Handle optional bodies cleanly: Apply an undefined-check wrapper on optional body params to preserve fallback behavior for absent bodies instead of causing TS2345 compiler errors or runtime crashes.
- Fix SSE client mapping: Ensure `*Stream` methods structurally map the outbound payload (e.g., `createRecipeStream` properly invokes its matching DTO mapper).
- Align `buildZodType` primitive fallback behavior with the server implementation.

Verification:
- Added comprehensive integration test spec covering nested arrays, enum-refs, free-form objects, nullable items, and optional bodies.
- All 6 sandbox outputs pass validation (`npm test` exit 0, `tsc` compilation success).

Deferred: Non-model SSE event naming conventions, ResponseSchema collisions, query key escaping, and `buildZodType` duplication.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/resources/typescript-express-zod-server/schemas.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/typescript-express-zod-server/schemas.mustache:82">
P1: Model schema exports and per-operation response schema exports share the same `*Schema` naming convention in `schemas.ts`, which can produce duplicate export names when a model class happens to match an operation response schema name (e.g., model `LoginResponse` and operationId `login` both emit `LoginResponseSchema`). Consider adding a reserved prefix or collision detection to keep the two namespaces distinct.</violation>
</file>

<file name="modules/openapi-generator/src/main/resources/typescript-express-zod-client/schemas.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/typescript-express-zod-client/schemas.mustache:66">
P1: Operation response schema names can collide with model schema exports in the same `schemas.ts` module. For example, a model named `LoginResponse` produces `export const LoginResponseSchema = ...`, and an operation with `operationId: "login"` produces the same export name. This causes a TypeScript redeclaration error at build time because there is no deduplication logic in the generator. Consider renaming operation response schemas to a pattern that cannot overlap with model schema names, such as prefixing with the operation ID explicitly or checking for collisions during code generation.</violation>
</file>

<file name="modules/openapi-generator/src/main/resources/typescript-express-zod-client/http-client.mustache">

<violation number="1" location="modules/openapi-generator/src/main/resources/typescript-express-zod-client/http-client.mustache:202">
P2: The SSE return type is constructed as `types.{{{sseType}}}` where `sseType` is set from `sseMt.getSchema().getComplexType()` or `getDataType()`. For simple model refs this yields `types.Pet`, which is fine. However, `getDataType()` can return composite TypeScript expressions such as `Array<Pet>`, `Pet[]`, or union types like `Pet | Cat`. Prepending `types.` to these produces invalid TypeScript (e.g., `types.Array<Pet>` or `types.Pet | Cat`). In contrast, the non-SSE response path uses `responseDomainGeneric`, which is recursively built by `TypescriptExpressZodSupport.resolveResponse()` and correctly qualifies nested types. The SSE path bypasses this robust resolution, creating a real bug for streaming endpoints with array, union, or generic event types.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

{{/allOperations}}
{{#allOperations}}
{{#responseType}}
export const {{operationIdPascal}}ResponseSchema = {{{responseZodExpr}}};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Model schema exports and per-operation response schema exports share the same *Schema naming convention in schemas.ts, which can produce duplicate export names when a model class happens to match an operation response schema name (e.g., model LoginResponse and operationId login both emit LoginResponseSchema). Consider adding a reserved prefix or collision detection to keep the two namespaces distinct.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/typescript-express-zod-server/schemas.mustache, line 82:

<comment>Model schema exports and per-operation response schema exports share the same `*Schema` naming convention in `schemas.ts`, which can produce duplicate export names when a model class happens to match an operation response schema name (e.g., model `LoginResponse` and operationId `login` both emit `LoginResponseSchema`). Consider adding a reserved prefix or collision detection to keep the two namespaces distinct.</comment>

<file context>
@@ -71,9 +71,15 @@ export const {{operationIdPascal}}ParamsSchema = z.object({
 {{/allOperations}}
+{{#allOperations}}
+{{#responseType}}
+export const {{operationIdPascal}}ResponseSchema = {{{responseZodExpr}}};
+
+{{/responseType}}
</file context>

{{/models}}
{{#allOperations}}
{{#responseType}}
export const {{operationIdPascal}}ResponseSchema = {{{responseZodExpr}}};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Operation response schema names can collide with model schema exports in the same schemas.ts module. For example, a model named LoginResponse produces export const LoginResponseSchema = ..., and an operation with operationId: "login" produces the same export name. This causes a TypeScript redeclaration error at build time because there is no deduplication logic in the generator. Consider renaming operation response schemas to a pattern that cannot overlap with model schema names, such as prefixing with the operation ID explicitly or checking for collisions during code generation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/typescript-express-zod-client/schemas.mustache, line 66:

<comment>Operation response schema names can collide with model schema exports in the same `schemas.ts` module. For example, a model named `LoginResponse` produces `export const LoginResponseSchema = ...`, and an operation with `operationId: "login"` produces the same export name. This causes a TypeScript redeclaration error at build time because there is no deduplication logic in the generator. Consider renaming operation response schemas to a pattern that cannot overlap with model schema names, such as prefixing with the operation ID explicitly or checking for collisions during code generation.</comment>

<file context>
@@ -61,3 +61,9 @@ export const {{classname}}Schema = z.object({}).catchall(z.unknown());
 {{/models}}
+{{#allOperations}}
+{{#responseType}}
+export const {{operationIdPascal}}ResponseSchema = {{{responseZodExpr}}};
+
+{{/responseType}}
</file context>


{{#hasSSE}}
/** {{{description}}} (streaming) */
async *{{streamOperationId}}({{#hasParams}}params{{#allParamsOptional}}?{{/allParamsOptional}}: types.{{operationIdPascal}}Params{{/hasParams}}): AsyncGenerator<types.{{{sseType}}}> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The SSE return type is constructed as types.{{{sseType}}} where sseType is set from sseMt.getSchema().getComplexType() or getDataType(). For simple model refs this yields types.Pet, which is fine. However, getDataType() can return composite TypeScript expressions such as Array<Pet>, Pet[], or union types like Pet | Cat. Prepending types. to these produces invalid TypeScript (e.g., types.Array<Pet> or types.Pet | Cat). In contrast, the non-SSE response path uses responseDomainGeneric, which is recursively built by TypescriptExpressZodSupport.resolveResponse() and correctly qualifies nested types. The SSE path bypasses this robust resolution, creating a real bug for streaming endpoints with array, union, or generic event types.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/resources/typescript-express-zod-client/http-client.mustache, line 202:

<comment>The SSE return type is constructed as `types.{{{sseType}}}` where `sseType` is set from `sseMt.getSchema().getComplexType()` or `getDataType()`. For simple model refs this yields `types.Pet`, which is fine. However, `getDataType()` can return composite TypeScript expressions such as `Array<Pet>`, `Pet[]`, or union types like `Pet | Cat`. Prepending `types.` to these produces invalid TypeScript (e.g., `types.Array<Pet>` or `types.Pet | Cat`). In contrast, the non-SSE response path uses `responseDomainGeneric`, which is recursively built by `TypescriptExpressZodSupport.resolveResponse()` and correctly qualifies nested types. The SSE path bypasses this robust resolution, creating a real bug for streaming endpoints with array, union, or generic event types.</comment>

<file context>
@@ -191,15 +191,15 @@ export class Http{{name}} implements types.{{name}} {
-  /** {{description}} (streaming) */
-  async *{{streamOperationId}}({{#hasParams}}params{{#allParamsOptional}}?{{/allParamsOptional}}: types.{{operationIdPascal}}Params{{/hasParams}}): AsyncGenerator<types.{{sseType}}> {
+  /** {{{description}}} (streaming) */
+  async *{{streamOperationId}}({{#hasParams}}params{{#allParamsOptional}}?{{/allParamsOptional}}: types.{{operationIdPascal}}Params{{/hasParams}}): AsyncGenerator<types.{{{sseType}}}> {
 {{#hasQueryParams}}
     const query = new URLSearchParams();
</file context>

@wing328

wing328 commented Jul 6, 2026

Copy link
Copy Markdown
Member

Thanks for the PR

cc @TiFu (2017/07) @taxpon (2017/07) @sebastianhaas (2017/07) @kenisteward (2017/07) @Vrolijkx (2017/09) @macjohnny (2018/01) @topce (2018/10) @akehir (2019/07) @petejohansonxo (2019/11) @amakhrov (2020/02) @davidgamero (2022/03) @mkusaka (2022/04) @joscha (2024/10) @dennisameling (2026/02)

@macjohnny

Copy link
Copy Markdown
Member

thanks for your contribution!

note, https://github.com/openapitools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptClientCodegen.java is a unification of the existing other typescript client generators.

@istvanfedak

Copy link
Copy Markdown
Author

thanks for your contribution!

note, https://github.com/openapitools/openapi-generator/blob/master/modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptClientCodegen.java is a unification of the existing other typescript client generators.

@wing328 @macjohnny thanks for taking a look at this. Should I avoid creating a the following new code gen files and use the existing file:

@macjohnny

Copy link
Copy Markdown
Member

@istvanfedak yeah the unified generator should make it easier, see also #6341

@istvanfedak

Copy link
Copy Markdown
Author

@macjohnny thanks for the context. Looking at the TypeScriptClientCodegen.java file I see that there are two current frameworks fetch-api and jquery that fit a single model structure and the main difference is the http transport implementation where this.frameworkToHttpLibMap cleanly maps the changes during the implementation

The TypeScript Express Zod client (which is tightly coupled with the TypeScript Express Zod server) differs a a bit. It consumes a framework agnostic fetch implementation allowing the consumer to pass in a fetch like function. It also adds a layer of object validation/filtering with zod before sending the request to the server

I'm thinking of adding some branching logic to TypeScriptClientCodegen.java in order to route the generation to TypeScriptExpressZodClientUtils.java when the framework is express-zod

Would this be a good approach to your comment?

Another thing to note is the client doesn't use express but for the naming convention I thought naming them the same would emphasize that tight coupling with the server

… to typescript-express-zod/petstore.yaml

Excludes regenerated samples/ output (committed separately).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NpXMTYcZS92CKpMmtsjSTp
Regenerated output for the unified typescript generator migration and
spec relocation to typescript-express-zod/petstore.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NpXMTYcZS92CKpMmtsjSTp

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 issues found across 39 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodClientUtils.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodClientUtils.java:48">
P1: Path template construction uses raw OpenAPI placeholder text as JavaScript property names instead of the normalized parameter name, causing runtime failures for paths with non-identifier characters (e.g., `user-id` becomes the invalid expression `params.user-id`). Use the corresponding `paramName` from the `pathParams` metadata when building the URL template literal.</violation>

<violation number="2" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodClientUtils.java:95">
P1: Operation metadata prepared for client templates drops header and cookie parameters entirely, so the generated HTTP client cannot serialize required headers or cookies into requests. Add `headerParams` and `cookieParams` to the `opInfo` map (similar to how `pathParams` and `queryParams` are added), and include them in the `x-has-no-params` / `hasParams` checks so templates know when these parameters are present.</violation>
</file>

<file name="docs/generators/typescript-express-zod-server.md">

<violation number="1" location="docs/generators/typescript-express-zod-server.md:267">
P1: The Schema Support table incorrectly marks `oneOf` and `Union` as unsupported, but the generator implements discriminated-union handling (including required discriminator properties) as described in the PR. These rows should reflect support.</violation>

<violation number="2" location="docs/generators/typescript-express-zod-server.md:273">
P2: The Security Feature table overstates authentication support. Because the generated server delegates authentication to a user-supplied middleware placeholder and does not generate scheme-specific enforcement, BasicAuth, ApiKey, and OAuth2_Implicit should be marked unsupported.</violation>

<violation number="3" location="docs/generators/typescript-express-zod-server.md:288">
P2: The Wire Format table claims XML is supported, but the generated handler pipeline uses `res.json()` and Zod schemas tailored for JSON. Without XML serialization or content negotiation in the templates, XML should be marked unsupported.</violation>
</file>

<file name="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java">

<violation number="1" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java:77">
P2: The `zodExpr()` fallback returns `z.any()` for property shapes that are not arrays, maps, $refs, or primitives. A plain OpenAPI `type: object` (free-form object without typed `additionalProperties`) will miss the map branch when `additionalProperties` is null and fall through to this fallback. `z.any()` is too permissive because it accepts strings, numbers, arrays, and null, whereas `type: object` should be restricted to objects. Consider adding an explicit branch for plain objects before the generic `z.any()` fallback — for example, using `z.object({}).passthrough()` or `z.record(z.string(), z.any())` depending on the desired openness.</violation>

<violation number="2" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java:126">
P1: The generated Zod schemas currently ignore several standard OpenAPI validation constraints, which allows contract-invalid data to pass runtime validation. `zodExpr()` handles `minimum`/`maximum` and `minLength`/`maxLength`, but drops `pattern` (string regex), `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`, array `minItems`/`maxItems`/`uniqueItems`, and object property-count bounds. Because this generator advertises runtime Zod validation as a core feature, these omissions directly weaken the server-side request validation and the client-side response validation. Consider extending `zodExpr()` (and the string/number/array/object branches) to emit the corresponding Zod modifiers, e.g., `.regex(...)` for `pattern`, `.gt(...)`/`.lt(...)` for exclusive bounds, `.multipleOf(...)` for `multipleOf`, and `.length(...)` for array bounds where `CodegenProperty` exposes them.</violation>

<violation number="3" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java:234">
P1: The non-discriminated oneOf detection only handles `$ref` members and silently drops inline schemas. In the consuming templates, `x-union-members` is used to emit `z.union([...])` and the corresponding TypeScript union type, so an all-inline oneOf produces an empty member list and invalid generated code (`z.union([])`). A mixed inline/ref oneOf loses its inline alternatives altogether. Consider extending the member-collection loop to also generate or resolve inline oneOf entries (e.g., inline primitives, arrays, or objects) so the union representation is complete.</violation>

<violation number="4" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java:489">
P1: Cycle breaking during topological sorting does not produce the `z.lazy()` references that Zod requires for recursive schemas. Self-references are stripped from `dependencies` yet still rendered as plain schema names by `zodExpr`, and mutually recursive models are emitted in an arbitrary order with direct forward references. The generated `schemas.ts` will fail at import time with a ReferenceError because `const` declarations are not hoisted. Consider tracking cyclic dependencies and emitting `z.lazy(() => ...)` for them in both the Java builder and the Mustache template.</violation>

<violation number="5" location="modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java:517">
P1: Top-level response and request-body Zod schemas are resolved without applying the outer `.nullable()` wrapper, so a nullable response or body is incorrectly rejected by validation. Model properties get `.nullable()` via `addZodVendorExtensions`, but `resolveResponse` and `resolveBody` call `zodExpr` directly and `shapeOf` never wraps for nullability either. The handler template then calls `schemas.OpResponseSchema.parse(payload)` with no null guard. You should extend `resolveResponse` to append `.nullable()` when `rp.isNullable`, and similarly guard `shapeOf`/`resolveBody` for nullable request bodies.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

@@ -0,0 +1,428 @@
package org.openapitools.codegen.languages;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Operation metadata prepared for client templates drops header and cookie parameters entirely, so the generated HTTP client cannot serialize required headers or cookies into requests. Add headerParams and cookieParams to the opInfo map (similar to how pathParams and queryParams are added), and include them in the x-has-no-params / hasParams checks so templates know when these parameters are present.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodClientUtils.java, line 95:

<comment>Operation metadata prepared for client templates drops header and cookie parameters entirely, so the generated HTTP client cannot serialize required headers or cookies into requests. Add `headerParams` and `cookieParams` to the `opInfo` map (similar to how `pathParams` and `queryParams` are added), and include them in the `x-has-no-params` / `hasParams` checks so templates know when these parameters are present.</comment>

<file context>
@@ -0,0 +1,428 @@
+                op.vendorExtensions.put("x-body-param-name", bodyParamName);
+            }
+
+            boolean hasNoParams = op.pathParams.isEmpty() && op.queryParams.isEmpty() && op.bodyParam == null;
+            op.vendorExtensions.put("x-has-no-params", hasNoParams);
+
</file context>

@@ -0,0 +1,428 @@
package org.openapitools.codegen.languages;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Path template construction uses raw OpenAPI placeholder text as JavaScript property names instead of the normalized parameter name, causing runtime failures for paths with non-identifier characters (e.g., user-id becomes the invalid expression params.user-id). Use the corresponding paramName from the pathParams metadata when building the URL template literal.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodClientUtils.java, line 48:

<comment>Path template construction uses raw OpenAPI placeholder text as JavaScript property names instead of the normalized parameter name, causing runtime failures for paths with non-identifier characters (e.g., `user-id` becomes the invalid expression `params.user-id`). Use the corresponding `paramName` from the `pathParams` metadata when building the URL template literal.</comment>

<file context>
@@ -0,0 +1,428 @@
+        for (CodegenOperation op : ops) {
+            // Build URL template for JS template literals:
+            // /recipe/{recipeId} -> /recipe/${encodeURIComponent(params.recipeId)}
+            String urlTemplate = op.path.replaceAll(
+                    "\\{([^}]+)\\}",
+                    "\\${encodeURIComponent(String(params.$1))}"
</file context>

|Union|✗|OAS3
|allOf|✗|OAS2,OAS3
|anyOf|✗|OAS3
|oneOf|✗|OAS3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The Schema Support table incorrectly marks oneOf and Union as unsupported, but the generator implements discriminated-union handling (including required discriminator properties) as described in the PR. These rows should reflect support.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/generators/typescript-express-zod-server.md, line 267:

<comment>The Schema Support table incorrectly marks `oneOf` and `Union` as unsupported, but the generator implements discriminated-union handling (including required discriminator properties) as described in the PR. These rows should reflect support.</comment>

<file context>
@@ -0,0 +1,290 @@
+|Union|✗|OAS3
+|allOf|✗|OAS2,OAS3
+|anyOf|✗|OAS3
+|oneOf|✗|OAS3
+|not|✗|OAS3
+
</file context>

}

// Primitive types.
if (prop.isInteger || prop.isLong) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The generated Zod schemas currently ignore several standard OpenAPI validation constraints, which allows contract-invalid data to pass runtime validation. zodExpr() handles minimum/maximum and minLength/maxLength, but drops pattern (string regex), exclusiveMinimum/exclusiveMaximum, multipleOf, array minItems/maxItems/uniqueItems, and object property-count bounds. Because this generator advertises runtime Zod validation as a core feature, these omissions directly weaken the server-side request validation and the client-side response validation. Consider extending zodExpr() (and the string/number/array/object branches) to emit the corresponding Zod modifiers, e.g., .regex(...) for pattern, .gt(...)/.lt(...) for exclusive bounds, .multipleOf(...) for multipleOf, and .length(...) for array bounds where CodegenProperty exposes them.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java, line 126:

<comment>The generated Zod schemas currently ignore several standard OpenAPI validation constraints, which allows contract-invalid data to pass runtime validation. `zodExpr()` handles `minimum`/`maximum` and `minLength`/`maxLength`, but drops `pattern` (string regex), `exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`, array `minItems`/`maxItems`/`uniqueItems`, and object property-count bounds. Because this generator advertises runtime Zod validation as a core feature, these omissions directly weaken the server-side request validation and the client-side response validation. Consider extending `zodExpr()` (and the string/number/array/object branches) to emit the corresponding Zod modifiers, e.g., `.regex(...)` for `pattern`, `.gt(...)`/`.lt(...)` for exclusive bounds, `.multipleOf(...)` for `multipleOf`, and `.length(...)` for array bounds where `CodegenProperty` exposes them.</comment>

<file context>
@@ -0,0 +1,645 @@
+        }
+
+        // Primitive types.
+        if (prop.isInteger || prop.isLong) {
+            StringBuilder sb = new StringBuilder("z.coerce.number().int()");
+            if (prop.minimum != null) sb.append(".gte(").append(prop.minimum).append(")");
</file context>

if (origSchema != null && origSchema.getOneOf() != null && !origSchema.getOneOf().isEmpty()) {
cm.vendorExtensions.put("x-is-union", true);

// Collect member classnames

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The non-discriminated oneOf detection only handles $ref members and silently drops inline schemas. In the consuming templates, x-union-members is used to emit z.union([...]) and the corresponding TypeScript union type, so an all-inline oneOf produces an empty member list and invalid generated code (z.union([])). A mixed inline/ref oneOf loses its inline alternatives altogether. Consider extending the member-collection loop to also generate or resolve inline oneOf entries (e.g., inline primitives, arrays, or objects) so the union representation is complete.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java, line 234:

<comment>The non-discriminated oneOf detection only handles `$ref` members and silently drops inline schemas. In the consuming templates, `x-union-members` is used to emit `z.union([...])` and the corresponding TypeScript union type, so an all-inline oneOf produces an empty member list and invalid generated code (`z.union([])`). A mixed inline/ref oneOf loses its inline alternatives altogether. Consider extending the member-collection loop to also generate or resolve inline oneOf entries (e.g., inline primitives, arrays, or objects) so the union representation is complete.</comment>

<file context>
@@ -0,0 +1,645 @@
+                    if (origSchema != null && origSchema.getOneOf() != null && !origSchema.getOneOf().isEmpty()) {
+                        cm.vendorExtensions.put("x-is-union", true);
+
+                        // Collect member classnames
+                        List<String> memberNames = new ArrayList<>();
+                        for (Object memberObj : origSchema.getOneOf()) {
</file context>

*/
static Resolved resolveResponse(CodegenOperation op, Direction dir) {
CodegenProperty rp = op.returnProperty;
String zod = zodExpr(rp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Top-level response and request-body Zod schemas are resolved without applying the outer .nullable() wrapper, so a nullable response or body is incorrectly rejected by validation. Model properties get .nullable() via addZodVendorExtensions, but resolveResponse and resolveBody call zodExpr directly and shapeOf never wraps for nullability either. The handler template then calls schemas.OpResponseSchema.parse(payload) with no null guard. You should extend resolveResponse to append .nullable() when rp.isNullable, and similarly guard shapeOf/resolveBody for nullable request bodies.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java, line 517:

<comment>Top-level response and request-body Zod schemas are resolved without applying the outer `.nullable()` wrapper, so a nullable response or body is incorrectly rejected by validation. Model properties get `.nullable()` via `addZodVendorExtensions`, but `resolveResponse` and `resolveBody` call `zodExpr` directly and `shapeOf` never wraps for nullability either. The handler template then calls `schemas.OpResponseSchema.parse(payload)` with no null guard. You should extend `resolveResponse` to append `.nullable()` when `rp.isNullable`, and similarly guard `shapeOf`/`resolveBody` for nullable request bodies.</comment>

<file context>
@@ -0,0 +1,645 @@
+     */
+    static Resolved resolveResponse(CodegenOperation op, Direction dir) {
+        CodegenProperty rp = op.returnProperty;
+        String zod = zodExpr(rp);
+        if (rp == null) {
+            // DefaultCodegen assigns returnType and returnProperty together, so this is
</file context>

Map<String, Set<String>> dependencies,
Set<String> visited, Set<String> visiting, List<Object> sorted) {
if (visited.contains(name)) return;
if (visiting.contains(name)) return; // cycle detected, break it

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Cycle breaking during topological sorting does not produce the z.lazy() references that Zod requires for recursive schemas. Self-references are stripped from dependencies yet still rendered as plain schema names by zodExpr, and mutually recursive models are emitted in an arbitrary order with direct forward references. The generated schemas.ts will fail at import time with a ReferenceError because const declarations are not hoisted. Consider tracking cyclic dependencies and emitting z.lazy(() => ...) for them in both the Java builder and the Mustache template.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java, line 489:

<comment>Cycle breaking during topological sorting does not produce the `z.lazy()` references that Zod requires for recursive schemas. Self-references are stripped from `dependencies` yet still rendered as plain schema names by `zodExpr`, and mutually recursive models are emitted in an arbitrary order with direct forward references. The generated `schemas.ts` will fail at import time with a ReferenceError because `const` declarations are not hoisted. Consider tracking cyclic dependencies and emitting `z.lazy(() => ...)` for them in both the Java builder and the Mustache template.</comment>

<file context>
@@ -0,0 +1,645 @@
+                                  Map<String, Set<String>> dependencies,
+                                  Set<String> visited, Set<String> visiting, List<Object> sorted) {
+        if (visited.contains(name)) return;
+        if (visiting.contains(name)) return; // cycle detected, break it
+        visiting.add(name);
+
</file context>

### Security Feature
| Name | Supported | Defined By |
| ---- | --------- | ---------- |
|BasicAuth|✓|OAS2,OAS3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Security Feature table overstates authentication support. Because the generated server delegates authentication to a user-supplied middleware placeholder and does not generate scheme-specific enforcement, BasicAuth, ApiKey, and OAuth2_Implicit should be marked unsupported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/generators/typescript-express-zod-server.md, line 273:

<comment>The Security Feature table overstates authentication support. Because the generated server delegates authentication to a user-supplied middleware placeholder and does not generate scheme-specific enforcement, BasicAuth, ApiKey, and OAuth2_Implicit should be marked unsupported.</comment>

<file context>
@@ -0,0 +1,290 @@
+### Security Feature
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|BasicAuth|✓|OAS2,OAS3
+|ApiKey|✓|OAS2,OAS3
+|OpenIDConnect|✗|OAS3
</file context>

| Name | Supported | Defined By |
| ---- | --------- | ---------- |
|JSON|✓|OAS2,OAS3
|XML|✓|OAS2,OAS3

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Wire Format table claims XML is supported, but the generated handler pipeline uses res.json() and Zod schemas tailored for JSON. Without XML serialization or content negotiation in the templates, XML should be marked unsupported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/generators/typescript-express-zod-server.md, line 288:

<comment>The Wire Format table claims XML is supported, but the generated handler pipeline uses `res.json()` and Zod schemas tailored for JSON. Without XML serialization or content negotiation in the templates, XML should be marked unsupported.</comment>

<file context>
@@ -0,0 +1,290 @@
+| Name | Supported | Defined By |
+| ---- | --------- | ---------- |
+|JSON|✓|OAS2,OAS3
+|XML|✓|OAS2,OAS3
+|PROTOBUF|✗|ToolingExtension
+|Custom|✗|OAS2,OAS3
</file context>
Suggested change
|XML||OAS2,OAS3
+|XML||OAS2,OAS3

*/
static String zodExpr(CodegenProperty prop) {
if (prop == null) {
return "z.any()";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The zodExpr() fallback returns z.any() for property shapes that are not arrays, maps, $refs, or primitives. A plain OpenAPI type: object (free-form object without typed additionalProperties) will miss the map branch when additionalProperties is null and fall through to this fallback. z.any() is too permissive because it accepts strings, numbers, arrays, and null, whereas type: object should be restricted to objects. Consider adding an explicit branch for plain objects before the generic z.any() fallback — for example, using z.object({}).passthrough() or z.record(z.string(), z.any()) depending on the desired openness.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At modules/openapi-generator/src/main/java/org/openapitools/codegen/languages/TypeScriptExpressZodUtils.java, line 77:

<comment>The `zodExpr()` fallback returns `z.any()` for property shapes that are not arrays, maps, $refs, or primitives. A plain OpenAPI `type: object` (free-form object without typed `additionalProperties`) will miss the map branch when `additionalProperties` is null and fall through to this fallback. `z.any()` is too permissive because it accepts strings, numbers, arrays, and null, whereas `type: object` should be restricted to objects. Consider adding an explicit branch for plain objects before the generic `z.any()` fallback — for example, using `z.object({}).passthrough()` or `z.record(z.string(), z.any())` depending on the desired openness.</comment>

<file context>
@@ -0,0 +1,645 @@
+     */
+    static String zodExpr(CodegenProperty prop) {
+        if (prop == null) {
+            return "z.any()";
+        }
+
</file context>

@wing328 wing328 added this to the 7.24.0 milestone Jul 12, 2026
@macjohnny

macjohnny commented Jul 14, 2026

Copy link
Copy Markdown
Member

@istvanfedak i wont have time to look into it in more detail, but i guess now that you have the context you will make the right choice for your new generator ;-)

let me know when its ready to be merged

@istvanfedak

Copy link
Copy Markdown
Author

@macjohnny thanks! I will let you know once its ready to merge. I'll finish polishing the generator

@istvanfedak

Copy link
Copy Markdown
Author

@macjohnny I'm thinking of porting this over to OAS 3.1 would openapi-generator support Open API Spec 3.1? I see some generators use it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants