ReviewOS

stacks/stx

defineProps rejects an `interface` (needs `Record<string, unknown>`), which is the pattern the framework scaffolds 44 times — masked because `from 'stx'` never resolves

#1917
Closed glennmichael123 opened this 10 days ago ·
10 days ago

defineProps<T extends Record<string, unknown>> rejects an interface. A TypeScript interface has no implicit index signature, so it does not satisfy Record<string, unknown>; a type alias does.

This is the pattern the Stacks framework scaffolds. storage/framework/defaults/ declares props with interface 44 times and with type zero times.

Repro

<script server>
import { defineProps, withDefaults } from '@stacksjs/stx'

interface ValueCardProps {
  icon: string
  title: string
  description: string
}

const { icon, title, description } = withDefaults(defineProps<ValueCardProps>(), {})
</script>
ValueCard.stx:11:51  error TS2345  Argument of type 'ValueCardProps' is not assignable to parameter of type 'Record<string, unknown>'.
ValueCard.stx:11:63  error TS2344  Type 'ValueCardProps' does not satisfy the constraint 'Record<string, unknown>'.

Change interface ValueCardProps { to type ValueCardProps = { and nothing else:

  Checked 1 script block(s) and 3 template expression(s) in 1 file(s) — 0 error(s), 0 warning(s)

Why nobody has hit it

Two things hide it.

The docstring only shows the inline form. Every example in props.d.ts is defineProps<{ title: string }>(). An inline object literal is assignable to Record<string, unknown>, so the documented usage passes. The error appears only when the type is extracted and named — which is what you do as soon as it is reused, documented, or exported.

The import usually does not resolve. Apps write import { defineProps } from 'stx'. There is no stx package, no declare module 'stx', and no path mapping in the app templates — so the module is any, the constraint is never enforced, and the component silently type-checks. In one real app, fixing the specifier turned 18 Cannot find module 'stx' errors into 36 constraint errors across 18 components. The bug was fully masked until the import was corrected.

Worth noting the specifier is itself split upstream: stx's own docs/ and packages/stx/test/ are 116 @stacksjs/stx vs 114 stx, while storage/framework/defaults/ is 39-to-0 in favour of @stacksjs/stx. Apps copying from docs get a coin flip.

Fix

<T extends object> accepts both an interface and a type alias, and still rejects primitives — which is all the constraint needs to do here. definePropsWithValidation and any other helper with the same constraint want the same change.

Separately worth deciding whether stx should resolve at all — either ship it as an alias, or make the docs consistently say @stacksjs/stx. Right now the broken half of the docs is what keeps this bug invisible.

10 days ago

Fixed in 15c2ae1.

object replaces Record<string, unknown> — it accepts an interface and a type alias alike, and still rejects string, number and undefined, which is everything the constraint has to do. Applied to all seven signatures that carried it (defineProps, definePropsWithValidation, withDefaults, shape, EmitFn, defineEmits, defineExpose), not just the two in the report, through one named PropsShape so the reason lives in one place and the sites cannot drift apart later.

Two bodies also spread props into a Record<string, unknown> — which an interface cannot satisfy from either, so the constraint change surfaced a second TS2322 in the implementation. Those now cast, which states a fact about what spreading produces at runtime rather than papering over the constraint.

Both things you identified as hiding it are fixed too

The inline-only docstrings. An inline object literal has an implicit index signature, so every documented example was the one spelling that could not fail. The named form now comes first, with a note on why.

The stx specifier — and this turned out to be the more interesting half.

stx is not a mistake. client-script.ts:587 treats 'stx' and '@stacksjs/stx' identically and strips the import, handing the names to the block as runtime globals. It is a virtual specifier inside a .stx script block, and it is the house spelling: 164 uses across docs/ and src/ against 126 of the scoped name. So "make the docs consistently say @stacksjs/stx" would be fixing the wrong end — both work at runtime.

Also worth knowing before considering the alias option: stx on npm is not available to you. It was an unrelated 2017 package, unpublished 2025-04-29, so the name is tombstoned. Shipping a real alias package isn't on the table.

What was actually broken is that the checker didn't know about the virtual specifier and reported Cannot find module 'stx'. The generated ambient now aliases it — aliased to the real package rather than re-declaring its exports, so there's no second copy of the public API to drift out of sync, and only emitted when the package resolves, since a declare module pointing at nothing would put an error in an ambient file and take the whole run down with it (#1906's failure mode).

Scoped to this checker deliberately: the specifier is virtual only where the import is stripped, which is inside a .stx block. A .ts composable has a real import and must keep using the real package name.

The masking, demonstrated

Your repro, before and after the alias landed but before rebuilding dist:

a.stx (from 'stx')          -> TS2307 Cannot find module 'stx'
a.stx (after alias)         -> TS2345 + TS2344   <- the exact two errors from this issue
a.stx (after rebuild)       -> clean

Making the specifier resolve immediately produced the constraint errors it had been hiding — your 18-becomes-36 observation, reproduced in one file.

Verification

Two test files. props-interface.test.ts compiles fixtures against src/, not the package's subpath exports — a stale dist/ would otherwise let it pass while the source is broken. stx-specifier.test.ts asserts the alias resolves to the real declarations by checking a primitive is still rejected; an alias that resolved to any would silence the false error and keep the blindness.

Sabotage-checked: reverting PropsShape to Record<string, unknown> fails 3 of 4 interface tests, and removing the module alias fails exactly the 2 specifier tests that depend on it.

Full suite 11819 pass. The 4 failures on that run (craft binary resolution ×2, X-STX-Runtime ×2) reproduce on a clean checkout of HEAD and are unrelated.

One drift I noticed but did not touch

stx.d.ts:511 declares defineEmits<T extends string>() — an event-name union — while props.ts declares defineEmits<E extends PropsShape>() — an event-name-to-payload map. Those are different APIs under one name, and per CLAUDE.md item 40 the two implementations are meant to agree. That's a design call rather than a typo, so I left it. Worth its own issue if you want it reconciled.

Sign in to comment on this issue.