Skip to content

Commit b303e38

Browse files
committed
exp: using ustable tsgo generator for Integration.
1 parent 590de1b commit b303e38

9 files changed

Lines changed: 532 additions & 144 deletions

File tree

PLAN-jsdoc-text-generation.md

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
# Plan: Text-Based JSDoc for `onObject` in zts.ts
2+
3+
## Goal
4+
5+
Make JSDoc comments (`@deprecated`, `@desc`, `@default`, `@example`) appear in the
6+
TypeScript types generated by `onObject` in `zts.ts`.
7+
8+
## Problem
9+
10+
`makeInterfaceProp` attaches JSDoc via `addJsDoc`, which is a noop in tsgo:
11+
12+
```typescript
13+
/** @todo this one is effectively noop, doesn't work and no alternative yet */
14+
const addJsDoc = <T extends Node>(node: T, text: string) => {
15+
const jsdoc = f.createJSDoc([f.createJSDocText(text)]);
16+
return Object.assign(node, { jsDoc: [jsdoc] });
17+
};
18+
```
19+
20+
The JSDoc IS attached to the AST (`node.jsDoc` exists and has content), but tsgo's
21+
emitter strips it from ALL nodes — both individual nodes AND full interfaces parsed
22+
via virtual FS. This was confirmed by testing:
23+
24+
```
25+
# Parsed from virtual FS, printed via emitter:
26+
interface Foo {
27+
one: string; // JSDoc stripped
28+
two?: number; // JSDoc stripped
29+
}
30+
```
31+
32+
The `parseTypeLiteral` approach (parse text → get AST → printNode) **does not work**
33+
because `printNode` ignores JSDoc regardless of how the node was created.
34+
35+
## Constraint
36+
37+
`zodToTs()` returns `TypeNode`. The `Producer` type and `ZTSContext.makeAlias`
38+
interface cannot change. The `brandHandling` public API accepts callbacks returning
39+
`TypeNode`.
40+
41+
## Solution: Store Text Alongside AST
42+
43+
Build type literal text (with JSDoc) during `onObject` processing, store it in a
44+
WeakMap, and use it instead of `printNode` when outputting the type.
45+
46+
Key insight: `next()` is called depth-first. By the time we build the outer type
47+
literal's text, inner type literals' texts are already stored.
48+
49+
---
50+
51+
## Phase 1: Add `typeTextRegistry` to `zts.ts`
52+
53+
### 1.1 New export
54+
55+
Add a WeakMap that maps `TypeNode` to its pre-built text (with JSDoc):
56+
57+
```typescript
58+
/** @internal stores pre-built text for TypeNodes that need JSDoc in output */
59+
export const typeTextRegistry = new WeakMap<TypeNode, string>();
60+
```
61+
62+
### 1.2 Add helper to build property text
63+
64+
```typescript
65+
const safePropRegex = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
66+
67+
const buildPropText = (
68+
key: string | number,
69+
typeNode: TypeNode,
70+
{
71+
isOptional,
72+
hasUndefined,
73+
isDeprecated,
74+
comment,
75+
}: {
76+
isOptional?: boolean;
77+
hasUndefined?: boolean;
78+
isDeprecated?: boolean;
79+
comment?: string;
80+
},
81+
): string => {
82+
const keyText = safePropRegex.test(String(key)) ? String(key) : `"${key}"`;
83+
const opt = isOptional ? "?" : "";
84+
const typeText = typeTextRegistry.get(typeNode) ?? printNode(typeNode);
85+
const undef = hasUndefined ? " | undefined" : "";
86+
const lines: string[] = [];
87+
if (isDeprecated) lines.push("/** @deprecated */");
88+
if (comment) lines.push(`/** @desc ${comment} */`);
89+
lines.push(` ${keyText}${opt}: ${typeText}${undef};`);
90+
return lines.join("\n");
91+
};
92+
```
93+
94+
This naturally handles nesting: inner objects' texts are already in
95+
`typeTextRegistry` (processed depth-first via `next()`), so they appear with
96+
JSDoc. Non-object types fall back to `printNode`.
97+
98+
---
99+
100+
## Phase 2: Convert `onObject` to dual output
101+
102+
### 2.1 Modified `onObject`
103+
104+
```typescript
105+
const onObject: Producer = (
106+
obj: z.core.$ZodObject,
107+
{ isResponse, next, makeAlias },
108+
) => {
109+
const produce = () => {
110+
const entries = Object.entries(obj._zod.def.shape);
111+
const propTexts: string[] = [];
112+
const members = entries.map<TypeElement>(([key, value]) => {
113+
const { description: comment, deprecated: isDeprecated } =
114+
globalRegistry.get(value) || {};
115+
const isOptional =
116+
(isResponse ? value._zod.optout : value._zod.optin) === "optional";
117+
const hasUndefined =
118+
isOptional && !(value instanceof z.core.$ZodExactOptional);
119+
const typeNode = next(value);
120+
propTexts.push(
121+
buildPropText(key, typeNode, {
122+
isOptional,
123+
hasUndefined,
124+
isDeprecated,
125+
comment,
126+
}),
127+
);
128+
return makeInterfaceProp(key, typeNode, { isOptional, hasUndefined });
129+
});
130+
const typeNode = f.createTypeLiteralNode(members);
131+
const text = `{\n${propTexts.join("\n")}\n}`;
132+
typeTextRegistry.set(typeNode, text);
133+
return typeNode;
134+
};
135+
return hasCycle(obj, { io: isResponse ? "output" : "input" })
136+
? makeAlias(obj, produce)
137+
: produce();
138+
};
139+
```
140+
141+
### 2.2 How it works
142+
143+
1. For each property, `next(value)` processes the type recursively (depth-first)
144+
2. If the property type is also an object, `onObject` runs recursively, stores
145+
text in `typeTextRegistry`, and returns a `TypeLiteralNode`
146+
3. `buildPropText` checks `typeTextRegistry` for the type text, falls back to
147+
`printNode` for non-object types
148+
4. All property texts are joined into a type literal body with JSDoc
149+
5. The text is stored in `typeTextRegistry` keyed by the `TypeLiteralNode`
150+
151+
---
152+
153+
## Phase 3: Use stored text in `integration.ts`
154+
155+
### 3.1 Modify `#makeAlias`
156+
157+
```typescript
158+
#makeAlias(key: object, produce: () => TypeNode): TypeNode {
159+
let name = this.#aliases.get(key);
160+
if (!name) {
161+
name = `Type${this.#aliases.size + 1}`;
162+
this.#aliases.set(key, name);
163+
const node = produce();
164+
const stored = typeTextRegistry.get(node);
165+
this.#program.push(
166+
stored
167+
? `type ${name} = ${stored};`
168+
: (opts) => `type ${name} = ${printNode(node, opts)};`,
169+
);
170+
}
171+
return ensureTypeNode(name);
172+
}
173+
```
174+
175+
### 3.2 Modify `onEndpoint` type alias generation
176+
177+
```typescript
178+
const inputTypeNode = zodToTs(inputSchema, ctxIn);
179+
const inputText = typeTextRegistry.get(inputTypeNode);
180+
this.#program.push(
181+
inputText
182+
? `/** ${request} */\ntype ${inputTypeName} = ${inputText};`
183+
: (opts) =>
184+
`/** ${request} */\ntype ${inputTypeName} = ${printNode(inputTypeNode, opts)};`,
185+
);
186+
```
187+
188+
Same pattern for variant type nodes.
189+
190+
### 3.3 Import `typeTextRegistry`
191+
192+
```typescript
193+
import { typeTextRegistry } from "./zts";
194+
```
195+
196+
---
197+
198+
## Phase 4: Simplify `makeInterfaceProp`
199+
200+
### 4.1 Remove JSDoc handling
201+
202+
`makeInterfaceProp` no longer needs `comment` or `isDeprecated` options since
203+
`onObject` handles JSDoc via text. The `addJsDoc` function can be removed.
204+
205+
### 4.2 Remove unused imports
206+
207+
After removing `addJsDoc`'s `R.reject(R.isNil, ...)` call, check if `ramda` is
208+
still used in `typescript-api.ts`. Remove if not.
209+
210+
### 4.3 Keep `makeInterfaceProp` signature compatible
211+
212+
Since `makeInterfaceProp` is only used by `onObject`, the extra options can be
213+
removed without breaking the public API.
214+
215+
---
216+
217+
## Phase 5: Update tests
218+
219+
### 5.1 Snapshot updates
220+
221+
Run `pnpm -F express-zod-api test -- --run zts` and update snapshots. The output
222+
now includes JSDoc comments that were previously lost.
223+
224+
### 5.2 Key test cases
225+
226+
- **`z.object()` tests** (zts.spec.ts ~line 233-293): Properties with descriptions
227+
and deprecated metadata should now show JSDoc in output
228+
- **`z.optional()` tests**: isOptional/hasUndefined behavior unchanged
229+
- **`Issue #2352` tests**: Intersection dedup still works (members are still AST
230+
nodes; only the text representation is new)
231+
- **Integration snapshot**: Type aliases now contain JSDoc for object properties
232+
233+
### 5.3 Verify no regression
234+
235+
All 1062 tests must pass. Snapshots that change should only gain JSDoc comments,
236+
not lose any existing output.
237+
238+
---
239+
240+
## Edge Cases
241+
242+
### `intersect` helper
243+
244+
The `intersect` helper in `zts.ts` merges `TypeLiteralNode` members. After
245+
merging, a new `TypeLiteralNode` is created. This node won't have stored text
246+
in `typeTextRegistry`. Two options:
247+
248+
1. **Accept the limitation**: JSDoc on intersected object types is lost. This is
249+
rare (`z.intersection()` of two objects) and matches current behavior.
250+
2. **Build merged text**: Extract JSDoc from source texts and merge them. Complex
251+
and fragile — defer to a future phase if needed.
252+
253+
### Cyclic types
254+
255+
When `makeAlias` is used (cyclic schemas), the text is stored in `typeTextRegistry`
256+
and used directly in `#makeAlias`'s push. JSDoc is preserved.
257+
258+
### Nested objects
259+
260+
Handled naturally by depth-first processing. When building the outer object's text,
261+
inner objects' texts are already in `typeTextRegistry`.
262+
263+
---
264+
265+
## Risks
266+
267+
- **Formatting difference**: Text built manually may differ from `printNode` output.
268+
`printNode` uses tsgo's formatter. Manual text uses our formatting. Snapshots
269+
will capture any differences.
270+
271+
- **`intersect` text loss**: JSDoc on intersected object types is lost. Matches
272+
current behavior — not a regression.
273+
274+
- **Future tsgo fix**: If tsgo fixes the emitter to include JSDoc, we could
275+
revert to AST-only approach. The `typeTextRegistry` is additive and can be
276+
removed.
277+
278+
---
279+
280+
## Execution Order
281+
282+
1. Phase 1: Add `typeTextRegistry` and `buildPropText` to `zts.ts`
283+
2. Phase 2: Convert `onObject` to dual output (text + AST)
284+
3. Phase 3: Use stored text in `integration.ts`
285+
4. Phase 4: Simplify `makeInterfaceProp` (remove JSDoc handling)
286+
5. Phase 5: Update tests and snapshots

express-zod-api/src/integration.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import { IntegrationBase, interfaces } from "./integration-base";
99
import { shouldHaveContent, makeCleanId } from "./common-helpers";
1010
import { loadPeer } from "./peer-helpers";
1111
import type { Routing } from "./routing";
12-
import { ensureTypeNode, printNode, ts } from "./typescript-api";
12+
import {
13+
ensureTypeNode,
14+
printNode,
15+
type TypeNode,
16+
type PrintNodeOptions,
17+
type DeferredCode,
18+
} from "./typescript-api";
1319
import { walkRouting, withHead, type OnEndpoint } from "./routing-walker";
1420
import type { HandlingRules } from "./schema-walker";
1521
import { zodToTs } from "./zts";
@@ -55,7 +61,7 @@ interface IntegrationParams {
5561
* @example { MyBrand: (schema: typeof myBrandSchema, { next }) => createKeywordTypeNode(SyntaxKind.AnyKeyword)
5662
* @link https://www.npmjs.com/package/@express-zod-api/zod-plugin
5763
*/
58-
brandHandling?: HandlingRules<ts.TypeNode, ZTSContext>;
64+
brandHandling?: HandlingRules<TypeNode, ZTSContext>;
5965
/**
6066
* @desc Whether the server supports credentials in cross-origin requests.
6167
* @desc It sets `credentials: "include"` in Client default Implementation and `withCredentials` in Subscription.
@@ -68,7 +74,7 @@ interface IntegrationParams {
6874

6975
interface FormattedPrintingOptions {
7076
/** @desc Typescript printer options */
71-
printerOptions?: ts.PrinterOptions;
77+
printerOptions?: PrintNodeOptions;
7278
/**
7379
* @desc Typescript code formatter
7480
* @default prettier.format | oxfmt.format
@@ -77,12 +83,11 @@ interface FormattedPrintingOptions {
7783
}
7884

7985
export class Integration extends IntegrationBase {
80-
readonly #program: Array<string | ((opts?: ts.PrinterOptions) => string)> =
81-
[];
86+
readonly #program: Array<string | DeferredCode> = [];
8287
readonly #aliases = new Map<object, string>();
8388
#usage?: string;
8489

85-
#makeAlias(key: object, produce: () => ts.TypeNode): ts.TypeNode {
90+
#makeAlias(key: object, produce: () => TypeNode): TypeNode {
8691
let name = this.#aliases.get(key);
8792
if (!name) {
8893
name = `Type${this.#aliases.size + 1}`;
@@ -205,7 +210,7 @@ export class Integration extends IntegrationBase {
205210
);
206211
}
207212

208-
public print(printerOptions?: ts.PrinterOptions) {
213+
public print(printerOptions?: PrintNodeOptions) {
209214
const parts = this.#program.map((entry) =>
210215
typeof entry === "function" ? entry(printerOptions) : entry,
211216
);

0 commit comments

Comments
 (0)