Skip to content

finding(components): forwardRef + a props index signature silently erases every declared prop type — 11 renderers affected #4422

Description

@yinlianghui

Found while turning noImplicitAny on for #4353 (PR #4417). Observation-class: no user-visible symptom, no fix proposed here. It is a type-safety hole that noImplicitAny cannot catch, which is why turning that flag on does not close it.

What

A renderer written like this looks fully typed and is not:

const ActionBarRenderer = forwardRef(
  ({ schema, className, ...props }, ref) => { /* schema is `any` here */ },
);
// instantiated as forwardRef with props { schema: ActionBarSchema; [key: string]: any }

forwardRef's render function takes PropsWithoutRef of the props type, defined in @types/react (19.2.18) as:

Props extends any ? ("ref" extends keyof Props ? Omit< Props, "ref" > : Props) : Props

An index signature puts string into keyof Props, so "ref" extends keyof Props is always true and the Omit branch always runs. Omit over a type carrying a string index signature keeps only the index signature — every declared property is erased. The render function therefore receives { [x: string]: any }, and schema resolves through the index signature to any.

Measured in situ rather than inferred: inserting const probe: null = schema; into action-bar.tsx raised no error, which under strictNullChecks only any does.

Why this is worse than a plain missing annotation

  • It is silent. The props type is right there in the source, so the component reads as typed to every reviewer and to every tool.
  • noImplicitAny does not see it. The any is supplied explicitly by the index signature, so nothing is "implicit" and no TS7006 / TS7031 is raised for schema. What finding(components): noImplicitAny is off package-wide, and 26 renderer signatures depend on it #4353 saw was only the second-order damage — untyped callbacks downstream of the untyped schema.
  • It hides declaration/implementation drift indefinitely. That is not hypothetical: it is exactly what let the action renderers declare the deprecated ActionSchema while being written against UIActionSchema (sibling finding), a contradiction that produced four compiler errors the instant those values were given a real type.

Scope

11 files in packages/components instantiate forwardRef with a props type carrying [key: string]: any:

src/renderers/action/action-bar.tsx
src/renderers/action/action-group.tsx
src/renderers/action/action-menu.tsx
src/renderers/basic/div.tsx
src/renderers/basic/icon.tsx
src/renderers/basic/separator.tsx
src/renderers/basic/span.tsx
src/renderers/form/button.tsx
src/renderers/layout/card.tsx
src/renderers/layout/container.tsx
src/renderers/layout/stack.tsx

Not swept beyond this package. The pattern is not package-specific, so other packages' forwardRef renderers are worth the same grep before anyone sizes a fix.

Directions, not a recommendation

Deliberately not choosing here — this wants a decision, and the trade differs per component:

  1. Annotate the render function's parameter directly in addition to the type argument. The explicit annotation is not routed through PropsWithoutRef, so declared props survive. Smallest diff, one line per component; leaves the trap in place for the next author.
  2. Drop the index signature from the props type and name the pass-through props. Fixes it structurally and is what commandment Fix documentation deployment for www.objectui.org #6 actually asks for, but the registry genuinely spreads arbitrary props onto the underlying Shadcn component, so each component needs its real prop surface worked out.
  3. A shared props helper for registered renderers that is index-signature-free at the declaration boundary, with the spread handled in one place.

Whichever is chosen, note that turning strictness flags on will keep reporting clean on these files either way — the hole is invisible to the compiler by construction, so it needs a lint rule or a review convention to stay closed.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions