Skip to content

Commit 9ea2bc5

Browse files
authored
fix(docs): FieldSchema.extend() does not exist — the FAQ recommended a call that throws (#3890)
#3882 brought content/docs under the example type-check gate and left the open question: of the ~600 blocks still unmarked, is any GENUINE ROT rather than a fragment? Swept them. One real one. deployment/troubleshooting.mdx answered "How do I extend a built-in schema?" — a direct FAQ, not a sketch — with `FieldSchema.extend({ … })`. FieldSchema carries a .transform() that lowers author-facing sugar at parse time, so the exported value is a ZodPipe, not a ZodObject: `.extend` is undefined and the call throws. The example also used `z` without importing it. Rewritten to COMPOSE — parse with FieldSchema, validate the additions alongside it — verified to both type-check and run, and marked so the gate holds it. Composition also keeps the transform, which .extend() would discard. A divergence found while fixing it: the first attempt used FieldSchema.in.extend() and failed in CI with "Property 'in' does not exist on type 'ZodObject<…>'". The two builds emit DIFFERENT declarations for the same source — locally ZodPipe<ZodObject<…>>, in CI a plain ZodObject — while the runtime is unambiguous (bound ZodPipe, .extend undefined, confirmed after rm -rf dist && pnpm build). So CI's declaration contradicts the value it describes. Likely inference instability in the DTS bundling of these very large zod types; worth its own investigation. The final example uses only .parse(), so it is correct under either declaration. The sweep's METHOD is recorded in the gate docstring, because two traps make a naive pass report a confident "nothing found": 1. tsc stops after syntactic diagnostics and never runs the semantic pass. Marking all 780 blocks at once let ~200 broken fragments suppress type-checking for every other block; that run returned only TS1xxx codes, which reads exactly like "no rot" and proves nothing. Caught by injecting a deliberate type error and watching it go unreported. 2. Unimported type names resolve against the DOM lib — Plugin, Event, Response, Storage all exist there. A block missing its import reports "'version' does not exist in type 'Plugin'" against lib.dom's Plugin: an artefact, not drift. Three of the strongest-looking candidates were exactly this. Everything else is fragments, plus config-resolution.mdx, whose aspirational snippets a callout on the page already labels as design intent. Gate: 196 -> 197 checked examples.
1 parent 7ef20d0 commit 9ea2bc5

3 files changed

Lines changed: 97 additions & 4 deletions

File tree

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(docs): `FieldSchema.extend()` does not exist — the FAQ was recommending a call that throws
6+
7+
#3882 brought `content/docs` under the example type-check gate and left an open
8+
question: of the blocks still unmarked, is any of it **genuine rot** rather than a
9+
fragment? Swept them. One real one:
10+
11+
`content/docs/deployment/troubleshooting.mdx` answered *"How do I extend a
12+
built-in schema?"* with
13+
14+
```ts
15+
const CustomFieldSchema = FieldSchema.extend({ … }); // TypeError
16+
```
17+
18+
`FieldSchema` carries a `.transform()` that lowers author-facing sugar at parse
19+
time, which makes it a **`ZodPipe`, not a `ZodObject`**`.extend` is `undefined`
20+
on it (verified against the built package). Anyone following the FAQ got a
21+
`not a function` throw. The example also used `z` without importing it.
22+
23+
Rewritten to **compose** — parse with `FieldSchema`, validate your additions
24+
alongside it — verified to both type-check and run. `.in.extend()` is mentioned in
25+
prose as the merged-schema route, with the caveat that it skips the transform.
26+
27+
**A divergence worth knowing about, found while fixing this.** The first fix used
28+
`FieldSchema.in.extend(…)` in the checked block. It passed locally and failed in
29+
CI with `Property 'in' does not exist on type 'ZodObject<…>'` — the two builds
30+
emit **different declarations for the same source**: locally
31+
`z.ZodPipe<z.ZodObject<…>>`, in CI a plain `z.ZodObject`. The runtime is
32+
unambiguous (`bound ZodPipe`, `.extend === undefined`, verified after a clean
33+
`rm -rf dist && pnpm build`), so **CI's declaration contradicts the value it
34+
describes**`.extend()` type-checks there and throws at runtime. Probably
35+
inference instability in the DTS bundling of these very large zod types; worth its
36+
own investigation. The example now uses only `.parse()`, so it is correct under
37+
either declaration and the doc is not hostage to which one you get.
38+
39+
**The sweep's method is recorded in the gate's docstring**, because two traps make
40+
a naive pass report a confident "nothing found":
41+
42+
1. **`tsc` stops after syntactic diagnostics** — it never reaches the semantic
43+
pass. Marking all 780 blocks at once let ~200 broken fragments suppress
44+
type-checking for every other block; the run came back with only TS1xxx codes,
45+
which reads exactly like "no rot" and proves nothing. Verified by injecting a
46+
deliberate type error and watching it go unreported.
47+
2. **Unimported type names resolve against the DOM lib.** `Plugin`, `Event`,
48+
`Response`, `Storage` all exist there, so a block missing its import reports
49+
*"'version' does not exist in type 'Plugin'"* against `lib.dom`'s `Plugin`
50+
an artefact, not drift. Three of the strongest-looking candidates were this.
51+
52+
After both corrections the remaining blocks are fragments, plus
53+
`protocol/kernel/config-resolution.mdx`, whose aspirational snippets are already
54+
labelled as design intent by a callout on the page.

content/docs/deployment/troubleshooting.mdx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -359,16 +359,35 @@ Yes. All schemas work with plain JavaScript. You lose compile-time type checking
359359

360360
### How do I extend a built-in schema?
361361

362-
Use Zod's `.extend()` or `.merge()`:
362+
**Not with `.extend()` on the schema itself.** Several built-in schemas —
363+
`FieldSchema`, `ObjectSchema`, `ActionSchema` — carry a `.transform()` that lowers
364+
author-facing sugar at parse time, which makes the exported value a **`ZodPipe`,
365+
not a `ZodObject`**. `FieldSchema.extend` is `undefined`, so calling it throws
366+
`is not a function`.
363367

368+
Compose instead: parse with the built-in schema, and validate your additions
369+
alongside it. This keeps the transform running, which `.extend()` would discard.
370+
371+
{/* os:check */}
364372
```typescript
373+
import { z } from 'zod';
365374
import { FieldSchema } from '@objectstack/spec/data';
366375

367-
const CustomFieldSchema = FieldSchema.extend({
368-
customProperty: z.string().optional()
369-
});
376+
const CustomProps = z.object({ customProperty: z.string().optional() });
377+
378+
function parseCustomField(input: unknown) {
379+
return { ...FieldSchema.parse(input), ...CustomProps.parse(input) };
380+
}
381+
382+
parseCustomField({ name: 'code', type: 'text', customProperty: 'x' });
370383
```
371384

385+
If you genuinely need one merged schema object, `FieldSchema.in` is the
386+
`ZodObject` the pipe wraps, so `FieldSchema.in.extend({ … })` builds one — but
387+
the result **skips the transform**, so author-facing sugar the pipe would have
388+
lowered stays raw. Prefer the composition above unless you specifically want the
389+
untransformed shape.
390+
372391
### Where are the JSON Schemas for IDE autocomplete?
373392

374393
The bundled JSON Schema is at `packages/spec/json-schema/objectstack.json` (its `x-schema-count` field reports the total number of definitions). Per-domain schemas are in subfolders under `packages/spec/json-schema/` (e.g. `data/`, `ui/`, `api/`).

packages/spec/scripts/check-skill-examples.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,26 @@
2222
* still sees the block. A fence-meta tag like ` ```ts check ` would have punched
2323
* a hole in that gate.
2424
*
25+
* ── Sweeping the UNMARKED blocks: two traps ─────────────────────────────────
26+
* Periodically it is worth asking whether any unmarked block is genuine ROT
27+
* rather than a fragment. Two things make a naive sweep report a confident
28+
* "nothing found", and both bit a real attempt:
29+
*
30+
* 1. `tsc` reports syntactic diagnostics and then STOPS — it never runs the
31+
* semantic pass for the program. Mark every block at once and the ~200
32+
* syntactically-broken fragments suppress type-checking for ALL the rest,
33+
* so a run producing only TS1xxx codes proves nothing. Exclude the
34+
* syntax failures and re-run before concluding anything about rot.
35+
* 2. A block that omits its imports resolves bare type names against the DOM
36+
* lib: `Plugin`, `Event`, `Response`, `Storage`, `Selection` all exist
37+
* there. `const p: Plugin = { version, init }` then reports "version does
38+
* not exist in type Plugin" against lib.dom's Plugin — an artefact, not
39+
* drift. Check what the name actually resolved to before filing it.
40+
*
41+
* The 2026-07 sweep that applied both corrections found exactly one real rot in
42+
* `content/docs` (a FAQ recommending `FieldSchema.extend()`, impossible since
43+
* FieldSchema became a ZodPipe); everything else was fragments.
44+
*
2545
* Each marked block is written verbatim to a throwaway build dir and type-checked
2646
* with `tsc --noEmit` against the built `@objectstack/spec` declarations — the
2747
* exact surface a consumer's `import { … } from '@objectstack/spec'` resolves to.

0 commit comments

Comments
 (0)