Found while burning down #4115's ledger in objectui (objectui#3042). The rule #4115 established — a spec-named symbol must be an import, not a declaration — is wrong for exactly four symbols, and there is no way for the consumer to know that from the outside without probing for it.
The mechanism
A recursive zod schema needs an explicit annotation to break the circular inference. The spec annotates with z.ZodType<any>, and then derives the type from it:
// dist/…d.tsdeclareconstNavigationItemSchema: z.ZodType<any>;typeNavigationItem=z.infer<typeofNavigationItemSchema>;// → any
So the type export is any. Same shape for all four:
| type export | subpath(s) | schema |
|---|
NavigationItem | ./ui, ./system | NavigationItemSchema: z.ZodType<any> |
FormField | ./ui | FormFieldSchema: z.ZodType<any> |
JoinNode | ./data | JoinNodeSchema: z.ZodType<any> |
NormalizedFilter | ./data | NormalizedFilterSchema: z.ZodType<any> |
Measured against @objectstack/spec@17.0.0-rc.0: 4 of 2240 exported types resolve to any. The scope is small, which is the good news — this is a tightly bounded fix, not a systemic rewrite.
Why it's worth a P2 rather than a footnote
#4115 tells every objectui package that a local declaration under a spec export's name must be replaced by a binding to the spec. For these four, obeying that instruction replaces a precise type with any.
Concretely: objectui's NavigationItem is a 118-line documented interface (recordId template variables, requiresObject / requiresService runtime capability gates, filters precedence rules). Every one of its keys exists in the spec's version — I checked, Exclude<keyof Local, keyof Spec> is never — so by every available signal it is a redundant fork that should be deleted in favour of the import. Deleting it swaps a fully-typed interface for any.
I made that exact edit before catching it, along with JoinNode, and had to revert both.
The reason it is hard to catch is that any is mutually assignable with everything, so the natural check reports these as identical to the spec and recommends precisely the wrong action:
typeA=[Local]extends[Spec] ? true : false// true, because Spec is anytypeB=[Spec]extends[Local] ? true : false// true, because Spec is any// A && B → "identical, safe to re-export"
This is the same failure family as #4075's [key: string]: any on ActionDef: a type that answers every question affirmatively reads as agreement. It also means the damage is silent — no compile error anywhere, just a type that stopped constraining anything.
A related trap sits next door: objectui's FormFieldSchema and the spec's have an identical _output and a divergent _input, so even comparing the parsed shapes would have said "same" while the two schemas accept different authoring input.
The fix
The standard zod recursion pattern — annotate with the interface instead of any, so z.infer gives it back:
interfaceNavigationItem{id: string;type: NavigationItemType;children?: NavigationItem[];/* … */}constNavigationItemSchema: z.ZodType<NavigationItem>=z.lazy(()=>z.object({/* … */}));z.infer then resolves to NavigationItem rather than any, and consumers that follow #4115's rule get the type they were promised.
The check
Same meta-rule as #4115 and #4118: this should land as CI, not as a note, or the next recursive schema reintroduces it. The whole check is a scan over each subpath's .d.ts:
constty=checker.getDeclaredTypeOfSymbol(sym);if(ty.flags&ts.TypeFlags.Any)fail(`${subpath}:${name} is an exported type that resolves to \`any\``);Ratchet it the way check-type-check-coverage.mjs does — declared, reasoned, shrink-only — so the four current ones can be listed while nothing new joins them.
Consumer-side tripwire already in place
objectui#3042 pins this from the outside, so no coordination is needed on the release:
typeIsAny<T>=0extends1&T ? true : false;const_specNavigationItemIsStillAny=truesatisfiesIsAny<SpecNavigationItem>;const_specJoinNodeIsStillAny=truesatisfiesIsAny<SpecJoinNode>;const_specFormFieldIsStillAny=truesatisfiesIsAny<SpecFormField>;
The assertion is deliberately inverted — it asserts these are stillany. The day this issue is fixed, true satisfies false stops compiling in objectui's spec-derived-unions.test.ts, and the failure names the symbol whose ledger entry can now be burned down. So fixing this here automatically tells objectui to finish the job, rather than the two repos needing to stay in sync by hand.
NormalizedFilter has no objectui collision today, so it carries no tripwire — it is in scope here only because it is the same defect.
Refs #4115, #4075, objectui#3042, objectui#3003, objectui#3009.
Found while burning down #4115's ledger in objectui (objectui#3042). The rule #4115 established — a spec-named symbol must be an import, not a declaration — is wrong for exactly four symbols, and there is no way for the consumer to know that from the outside without probing for it.
The mechanism
A recursive zod schema needs an explicit annotation to break the circular inference. The spec annotates with
z.ZodType<any>, and then derives the type from it:So the type export is
any. Same shape for all four:NavigationItem./ui,./systemNavigationItemSchema: z.ZodType<any>FormField./uiFormFieldSchema: z.ZodType<any>JoinNode./dataJoinNodeSchema: z.ZodType<any>NormalizedFilter./dataNormalizedFilterSchema: z.ZodType<any>Measured against
@objectstack/spec@17.0.0-rc.0: 4 of 2240 exported types resolve toany. The scope is small, which is the good news — this is a tightly bounded fix, not a systemic rewrite.Why it's worth a P2 rather than a footnote
#4115 tells every objectui package that a local declaration under a spec export's name must be replaced by a binding to the spec. For these four, obeying that instruction replaces a precise type with
any.Concretely: objectui's
NavigationItemis a 118-line documented interface (recordIdtemplate variables,requiresObject/requiresServiceruntime capability gates,filtersprecedence rules). Every one of its keys exists in the spec's version — I checked,Exclude<keyof Local, keyof Spec>isnever— so by every available signal it is a redundant fork that should be deleted in favour of the import. Deleting it swaps a fully-typed interface forany.I made that exact edit before catching it, along with
JoinNode, and had to revert both.The reason it is hard to catch is that
anyis mutually assignable with everything, so the natural check reports these as identical to the spec and recommends precisely the wrong action:This is the same failure family as #4075's
[key: string]: anyonActionDef: a type that answers every question affirmatively reads as agreement. It also means the damage is silent — no compile error anywhere, just a type that stopped constraining anything.A related trap sits next door: objectui's
FormFieldSchemaand the spec's have an identical_outputand a divergent_input, so even comparing the parsed shapes would have said "same" while the two schemas accept different authoring input.The fix
The standard zod recursion pattern — annotate with the interface instead of
any, soz.infergives it back:z.inferthen resolves toNavigationItemrather thanany, and consumers that follow #4115's rule get the type they were promised.The check
Same meta-rule as #4115 and #4118: this should land as CI, not as a note, or the next recursive schema reintroduces it. The whole check is a scan over each subpath's
.d.ts:Ratchet it the way
check-type-check-coverage.mjsdoes — declared, reasoned, shrink-only — so the four current ones can be listed while nothing new joins them.Consumer-side tripwire already in place
objectui#3042 pins this from the outside, so no coordination is needed on the release:
The assertion is deliberately inverted — it asserts these are still
any. The day this issue is fixed,true satisfies falsestops compiling in objectui'sspec-derived-unions.test.ts, and the failure names the symbol whose ledger entry can now be burned down. So fixing this here automatically tells objectui to finish the job, rather than the two repos needing to stay in sync by hand.NormalizedFilterhas no objectui collision today, so it carries no tripwire — it is in scope here only because it is the same defect.Refs #4115, #4075, objectui#3042, objectui#3003, objectui#3009.