Skip to content

Commit 436961c

Browse files
committed
chore: release version 0.5.2
- Fix: resolve crash in `z.date()` and `z.coerce.date()` handling in JSON Schema generation - Update CHANGELOG for version 0.5.2 - Enhance README to document date field representation - Add new tests for array, edge cases, enums, intersections, literals, objects, output schemas, primitives, records, route metadata, TypeScript types, unions, and zod date support - Introduce helper functions for route testing - Ensure proper handling of optional and nullable fields in schemas
1 parent 62f0b3d commit 436961c

19 files changed

+832
-6
lines changed

CHANGELOG.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
66
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.5.2] - 2026-02-24
9+
10+
### Fixed
11+
12+
- **`z.date()` / `z.coerce.date()` crash** - Fixed a bug where procedures that used Zod date schemas
13+
caused schema conversion to throw `"Transforms cannot be represented in JSON Schema"`, resulting
14+
in no documentation being generated for those routes
15+
- `z.date()` and `z.coerce.date()` fields are now correctly represented as
16+
`{ type: "string", format: "date-time" }` in the generated JSON Schema, matching the ISO 8601
17+
strings that tRPC transports over the wire
18+
- Example values for date fields are rendered as a realistic ISO timestamp (e.g.
19+
`"2024-01-01T00:00:00.000Z"`) instead of the generic `"string"` placeholder
20+
- Other normally-unrepresentable types (transforms, functions) now fall back gracefully to an
21+
unconstrained schema instead of hard-crashing
22+
823
## [0.5.1] - 2026-01-28
924

1025
### Fixed
@@ -118,7 +133,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
118133
- `Fixed` - Bug fixes
119134
- `Security` - Vulnerability fixes
120135

121-
[Unreleased]: https://github.com/liorcohen/trpc-docs-generator/compare/v0.5.1...HEAD
136+
[Unreleased]: https://github.com/liorcohen/trpc-docs-generator/compare/v0.5.2...HEAD
137+
[0.5.2]: https://github.com/liorcohen/trpc-docs-generator/compare/v0.5.1...v0.5.2
122138
[0.5.1]: https://github.com/liorcohen/trpc-docs-generator/compare/v0.5.0...v0.5.1
123139
[0.5.0]: https://github.com/liorcohen/trpc-docs-generator/releases/tag/v0.5.0
124140
[0.1.0]: https://github.com/liorcohen/trpc-docs-generator/releases/tag/v0.1.0

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ Powered by Zod's `toJSONSchema()` method, the generator automatically:
9090
- Creates TypeScript type definitions
9191
- Identifies optional vs required fields
9292
- Handles complex types (unions, intersections, arrays, enums, records)
93+
- **Date fields** - `z.date()` and `z.coerce.date()` are represented as ISO 8601 strings
94+
(`{ type: "string", format: "date-time" }`), matching how tRPC transports dates over the wire
9395

9496
### 🎨 Modern, Beautiful UI
9597

bun.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "trpc-docs-generator",
3-
"version": "0.5.1",
3+
"version": "0.5.2",
44
"author": "Lior Cohen",
55
"homepage": "https://github.com/liorcodev/trpc-docs-generator#readme",
66
"repository": {
@@ -15,10 +15,12 @@
1515
"module": "./dist/index.js",
1616
"devDependencies": {
1717
"@trpc/server": "^11.9.0",
18+
"@types/bun": "^1.3.9",
1819
"@types/node": "^25.0.10",
1920
"prettier": "^3.8.1",
2021
"sharp": "^0.34.5",
21-
"typescript": "^5.9.3"
22+
"typescript": "^5.9.3",
23+
"zod": "^4.3.6"
2224
},
2325
"exports": {
2426
".": {

src/collect-routes.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,12 @@ function generateJSONExample(
204204
if (schema.enum && schema.enum.length > 0) {
205205
return `"${schema.enum[0]}"`;
206206
}
207+
if (schema.format === 'date-time') {
208+
return '"2024-01-01T00:00:00.000Z"';
209+
}
210+
if (schema.format === 'date') {
211+
return '"2024-01-01"';
212+
}
207213
return '"string"';
208214
}
209215

@@ -370,7 +376,26 @@ function generateTypeScriptExample(schema: any, depth: number = 0): string {
370376
}
371377

372378
/**
373-
* Convert a Zod schema to JSON schema string using Zod v4+ toJSONSchema method
379+
* Determine whether a Zod schema node represents a date type.
380+
* Works with both Zod v3 (_def.typeName) and Zod v4 (def.type).
381+
*/
382+
function isZodDateSchema(zodSchema: unknown): boolean {
383+
if (!zodSchema || typeof zodSchema !== 'object') return false;
384+
const s = zodSchema as Record<string, any>;
385+
// Zod v3
386+
if (s._def?.typeName === 'ZodDate') return true;
387+
// Zod v4 – exposes a top-level .def object and also ._zod.def
388+
if (s.def?.type === 'date') return true;
389+
if (s._zod?.def?.type === 'date') return true;
390+
return false;
391+
}
392+
393+
/**
394+
* Convert a Zod schema to JSON schema string using Zod v4+ toJSONSchema method.
395+
* - Dates (z.date() / z.coerce.date()) are mapped to { type: "string", format: "date-time" }
396+
* because tRPC transports them as ISO 8601 strings.
397+
* - Other unrepresentable types (transforms, functions) are silently mapped to {} instead of
398+
* crashing, by passing unrepresentable: 'any'.
374399
* @param schema - Zod schema object
375400
* @returns JSON schema as string or undefined if conversion fails
376401
*/
@@ -379,9 +404,21 @@ function zodSchemaToString(schema: unknown): string | undefined {
379404
try {
380405
// Check if it has the toJSONSchema method (Zod v4+)
381406
if (schema && typeof schema === 'object' && 'toJSONSchema' in schema) {
382-
const toJSONSchema = (schema as { toJSONSchema: () => unknown }).toJSONSchema;
407+
const toJSONSchema = (schema as { toJSONSchema: (options?: unknown) => unknown })
408+
.toJSONSchema;
383409
if (typeof toJSONSchema === 'function') {
384-
const jsonSchema = toJSONSchema();
410+
const jsonSchema = toJSONSchema.call(schema, {
411+
// Don't throw on transforms/functions – fall back to an unconstrained schema instead.
412+
unrepresentable: 'any',
413+
override: (ctx: any) => {
414+
// Map Zod date schemas → { type: "string", format: "date-time" }.
415+
// This covers z.date() and z.coerce.date(), both of which transport as ISO strings.
416+
// NOTE: ctx.jsonSchema must be mutated in-place; reassigning the reference has no effect.
417+
if (isZodDateSchema(ctx.zodSchema)) {
418+
Object.assign(ctx.jsonSchema, { type: 'string', format: 'date-time' });
419+
}
420+
}
421+
});
385422
return JSON.stringify(jsonSchema, null, 2);
386423
}
387424
}

tests/_helpers.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { expect } from 'bun:test';
2+
import { type RouteInfo } from '../src/collect-routes';
3+
4+
export function getRoute(routes: RouteInfo[], path: string): RouteInfo {
5+
const route = routes.find(r => r.path === path);
6+
expect(route).toBeDefined();
7+
return route!;
8+
}
9+
10+
export function inputSchema(routes: RouteInfo[], path: string): any {
11+
const route = getRoute(routes, path);
12+
expect(route.inputSchema).toBeDefined();
13+
return JSON.parse(route.inputSchema!);
14+
}
15+
16+
export function outputSchema(routes: RouteInfo[], path: string): any {
17+
const route = getRoute(routes, path);
18+
expect(route.outputSchema).toBeDefined();
19+
return JSON.parse(route.outputSchema!);
20+
}
21+
22+
export function inputExample(routes: RouteInfo[], path: string): any {
23+
const route = getRoute(routes, path);
24+
expect(route.inputExample).toBeDefined();
25+
return JSON.parse(route.inputExample!);
26+
}
27+
28+
export function inputTypeScript(routes: RouteInfo[], path: string): string {
29+
const route = getRoute(routes, path);
30+
expect(route.inputTypeScript).toBeDefined();
31+
return route.inputTypeScript!;
32+
}

tests/arrays.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { initTRPC } from '@trpc/server';
3+
import { z } from 'zod';
4+
import { collectRoutes } from '../src/collect-routes';
5+
import { inputSchema, inputExample, inputTypeScript } from './_helpers';
6+
7+
const t = initTRPC.create();
8+
9+
const router = t.router({
10+
strArr: t.procedure.input(z.object({ tags: z.array(z.string()) })).query(() => ''),
11+
numArr: t.procedure.input(z.object({ ids: z.array(z.number()) })).query(() => ''),
12+
objArr: t.procedure
13+
.input(z.object({ items: z.array(z.object({ id: z.number(), name: z.string() })) }))
14+
.query(() => ''),
15+
});
16+
17+
describe('array types', () => {
18+
test('z.array(z.string()) → type:array in JSON schema', () => {
19+
expect(inputSchema(collectRoutes(router), 'strArr').properties.tags.type).toBe('array');
20+
});
21+
22+
test('z.array(z.string()) items → type:string', () => {
23+
expect(inputSchema(collectRoutes(router), 'strArr').properties.tags.items.type).toBe('string');
24+
});
25+
26+
test('z.array(z.string()) example is a JSON array', () => {
27+
const ex = inputExample(collectRoutes(router), 'strArr');
28+
expect(Array.isArray(ex.tags)).toBe(true);
29+
expect(ex.tags[0]).toBe('string');
30+
});
31+
32+
test('z.array(z.number()) items → type:number|integer', () => {
33+
const schema = inputSchema(collectRoutes(router), 'numArr');
34+
expect(['number', 'integer']).toContain(schema.properties.ids.items.type);
35+
});
36+
37+
test('z.array(z.object(...)) items → type:object', () => {
38+
expect(inputSchema(collectRoutes(router), 'objArr').properties.items.items.type).toBe('object');
39+
});
40+
41+
test('z.array(z.string()) TypeScript type → string[] or Array<string>', () => {
42+
expect(inputTypeScript(collectRoutes(router), 'strArr')).toMatch(/string\[\]|Array<string>/);
43+
});
44+
});

tests/edge-cases.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { initTRPC } from '@trpc/server';
3+
import { z } from 'zod';
4+
import { collectRoutes } from '../src/collect-routes';
5+
import { getRoute, inputSchema, inputExample } from './_helpers';
6+
7+
const t = initTRPC.create();
8+
9+
const router = t.router({
10+
// Deeply nested union inside object
11+
complex: t.procedure
12+
.input(
13+
z.object({
14+
filter: z.union([
15+
z.object({ type: z.literal('id'), value: z.number() }),
16+
z.object({ type: z.literal('name'), value: z.string() }),
17+
]),
18+
})
19+
)
20+
.query(() => ''),
21+
22+
// All-optional object (no required fields at all)
23+
allOptional: t.procedure
24+
.input(z.object({ a: z.string().optional(), b: z.number().optional() }))
25+
.query(() => ''),
26+
27+
// Nullable date
28+
nullableDate: t.procedure.input(z.object({ at: z.date().nullable() })).query(() => ''),
29+
});
30+
31+
describe('edge cases', () => {
32+
test('complex nested union does not throw', () => {
33+
expect(() => collectRoutes(router)).not.toThrow();
34+
});
35+
36+
test('all-optional object: example is empty {}', () => {
37+
expect(inputExample(collectRoutes(router), 'allOptional')).toEqual({});
38+
});
39+
40+
test('all-optional object: both fields appear in inputOptionalFields', () => {
41+
const route = getRoute(collectRoutes(router), 'allOptional');
42+
const names = route.inputOptionalFields?.map(f => f.name) ?? [];
43+
expect(names).toContain('a');
44+
expect(names).toContain('b');
45+
});
46+
47+
test('nullable z.date() does not throw', () => {
48+
expect(() => collectRoutes(router)).not.toThrow();
49+
});
50+
51+
test('nullable z.date() schema contains a date-time string variant', () => {
52+
const atSchema = inputSchema(collectRoutes(router), 'nullableDate').properties.at;
53+
const variants = atSchema.anyOf ?? atSchema.oneOf ?? [atSchema];
54+
const dateVariant = variants.find((s: any) => s.format === 'date-time');
55+
expect(dateVariant).toMatchObject({ type: 'string', format: 'date-time' });
56+
});
57+
});

tests/enums.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { initTRPC } from '@trpc/server';
3+
import { z } from 'zod';
4+
import { collectRoutes } from '../src/collect-routes';
5+
import { inputSchema, inputExample, inputTypeScript } from './_helpers';
6+
7+
const t = initTRPC.create();
8+
9+
const router = t.router({
10+
byStatus: t.procedure
11+
.input(z.object({ status: z.enum(['active', 'inactive', 'pending']) }))
12+
.query(() => ''),
13+
});
14+
15+
describe('enums', () => {
16+
test('z.enum() → type:string in JSON schema', () => {
17+
expect(inputSchema(collectRoutes(router), 'byStatus').properties.status.type).toBe('string');
18+
});
19+
20+
test('z.enum() → enum array in JSON schema', () => {
21+
expect(inputSchema(collectRoutes(router), 'byStatus').properties.status.enum).toEqual([
22+
'active',
23+
'inactive',
24+
'pending',
25+
]);
26+
});
27+
28+
test('z.enum() example uses first value', () => {
29+
expect(inputExample(collectRoutes(router), 'byStatus').status).toBe('active');
30+
});
31+
32+
test('z.enum() TypeScript type contains quoted union members', () => {
33+
const ts = inputTypeScript(collectRoutes(router), 'byStatus');
34+
expect(ts).toContain('"active"');
35+
expect(ts).toContain('"inactive"');
36+
expect(ts).toContain('"pending"');
37+
});
38+
});

tests/intersections.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, test, expect } from 'bun:test';
2+
import { initTRPC } from '@trpc/server';
3+
import { z } from 'zod';
4+
import { collectRoutes } from '../src/collect-routes';
5+
import { inputSchema, inputExample, inputTypeScript } from './_helpers';
6+
7+
const t = initTRPC.create();
8+
9+
const Base = z.object({ id: z.number() });
10+
const Extra = z.object({ name: z.string() });
11+
12+
const router = t.router({
13+
merged: t.procedure.input(Base.and(Extra)).query(() => ''),
14+
});
15+
16+
describe('intersection types', () => {
17+
test('z.intersection does not throw', () => {
18+
expect(() => collectRoutes(router)).not.toThrow();
19+
});
20+
21+
test('intersection schema contains allOf', () => {
22+
const schema = inputSchema(collectRoutes(router), 'merged');
23+
expect(schema.allOf).toBeDefined();
24+
expect(Array.isArray(schema.allOf)).toBe(true);
25+
});
26+
27+
test('intersection example has fields from both sides', () => {
28+
const ex = inputExample(collectRoutes(router), 'merged');
29+
expect(ex).toHaveProperty('id');
30+
expect(ex).toHaveProperty('name');
31+
});
32+
33+
test('intersection TypeScript type contains both field names', () => {
34+
const ts = inputTypeScript(collectRoutes(router), 'merged');
35+
expect(ts).toContain('id');
36+
expect(ts).toContain('name');
37+
});
38+
});

0 commit comments

Comments
 (0)