|
| 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 |
0 commit comments