Skip to content

[P2] spec: four exported types resolve to any, so "bind the spec-named symbol to the spec" actively degrades the consumer that obeys it #4171

Description

@os-zhuang

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.ts
declare const NavigationItemSchema: z.ZodType<any>;
type NavigationItem = z.infer<typeof NavigationItemSchema>;   // → 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:

type A = [Local] extends [Spec] ? true : false   // true, because Spec is any
type B = [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:

interface NavigationItem { id: string; type: NavigationItemType; children?: NavigationItem[]; /* … */ }
const NavigationItemSchema: z.ZodType&lt;NavigationItem&gt; = z.lazy(() =&gt; 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:

const ty = 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:

type IsAny<T> = 0 extends 1 & T ? true : false;
const _specNavigationItemIsStillAny = true satisfies IsAny&lt;SpecNavigationItem&gt;;
const _specJoinNodeIsStillAny = true satisfies IsAny&lt;SpecJoinNode&gt;;
const _specFormFieldIsStillAny = true satisfies IsAny&lt;SpecFormField&gt;;

The assertion is deliberately inverted — it asserts these are still any. 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.

Activity

  1. os-zhuang commented on Aug 1, 2026

    @os-zhuang
    ContributorAuthor

    再登记一个被类型擦除挡住的符号,并补一种此前没记录的擦除形态。

    JoinedReportBlockSchema 声明为 z.ZodTypeAny,其导出类型是 unknown(不是 any)。 objectui 的 JoinedReportBlock 因此无法 re-export——那会把一个精确 interface 换成 unknown。

    值得单独说的是它对分诊流程的影响:objectui 守卫(scripts/check-spec-symbol-derivation.mjs)文件头教的探针是 0 extends (1 & T) ? true : false,用来识别"某一侧解析为 any"这种会让可赋值性检查全部说谎的情形。这个探针对 unknown 返回 false —— 也就是说,只按原文档行事的分诊会认为该符号"两侧互相可赋值、可安全派生",然后删掉整份类型声明。objectui#3167 已在守卫头部补上 case 2b 并加了反向 pin(spec 把它修好那天测试会报红)。

    该符号已挂到 objectui#3162(等待本 issue 解除的批次)下,与 JoinNode / NavigationItem / NavigationItemSchema 同批。


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions