/design-sync .d.ts emitter produces declarations that reference types it never imports or defines

Status Open
Reported on v2.1.222
Maintainer reply None cached
Activity 0 comments · opened Sep 1, 2026

Summary

The dts.mjs type-emission module in the /design-sync toolchain writes
.d.ts files that reference type names (CSSProperties, ReactElement,
Ref<T>, and others, including the component's own locally-defined types)
without ever importing or declaring them. The emitted declarations do not
compile standalone. This is not an edge case: on one small 6-component
library, 6 of 9 emitted files carry the defect, across 13 distinct dangling
type names. The toolchain's own output gate, package-validate, reports
ok: true on this output every time, because it parses declarations
without resolving the type references inside them — so the pipeline
currently ships broken .d.ts files as a matter of course. Tracking ID on
our side: CAS-1765.

Environment

  • Claude Code 2.1.222
  • Vendored toolchain at packages/ui/.ds-sync/, dated 2026-07-23
  • TypeScript 5.9.x, @types/react 19.2.x, @mui/material 9.2.0
  • Target: a 6-component library (@worksperks/ui) synced via

.design-sync/config.json

Reproduction

npx tsc --noEmit --skipLibCheck false $(find <pkg>/ds-bundle -name "*.d.ts" | sort)

Real output from our bundle:

ds-bundle/components/general/Surface/Surface.d.ts(16,11): error TS2304: Cannot find name 'CSSProperties'.
ds-bundle/components/general/IosSwitch/IosSwitch.d.ts(24,9): error TS2314: Generic type 'Ref' requires 1 type argument(s).

--skipLibCheck false is required to see this — see Trap 1 below.

Root cause

All three defects live in typeText() in dts.mjs.

// dts.mjs:231
const KEEP_ALIAS = /^(ReactNode|ReactElement|CSSProperties|JSX\.Element|Key|Ref|RefObject)$/;

function typeText(t, at) {
  const alias = t.getAliasSymbol()?.getName();
  if (alias && KEEP_ALIAS.test(alias)) return `React.${alias}`;   // :235
  ...
  } else {
    s = t.getText(at, ts.TypeFormatFlags.NoTruncation).replace(/import\("[^"]*"\)\./g, '');  // :259
  }

Defect 1 — module qualifier stripped, nothing substituted (dts.mjs:259)

The .replace(/import\("[^"]*"\)\./g, '') call strips TypeScript's
import("…"). qualifier from the printed type text and substitutes
nothing. Any type that reaches this else branch is emitted bare —
referenced in the declaration but never imported or defined there.

Which branch a type takes: alias vs. interface, not union vs. object

The dispatch at dts.mjs:235 is not, as it might appear, a union/object-type
split. It hinges entirely on whether t.getAliasSymbol() resolves, which in
turn depends on whether the type is declared with type (a type alias,
which carries an alias symbol) or interface (which does not). Two
@types/react types that look equally "special-cased" in KEEP_ALIAS land
in opposite branches:

| Type | Declared in @types/react as | getAliasSymbol() | Branch taken | Emitted as |
|---|---|---|---|---|
| ReactNode | type ReactNode = … (index.d.ts:436) | resolves | KEEP_ALIAS (:235) | React.ReactNode — correct |
| CSSProperties | interface CSSProperties … (index.d.ts:2477) | undefined | else (:259) | CSSProperties — bare |

Defect 2 — type arguments discarded (dts.mjs:235)

return \React.${alias}\` returns only the alias's bare name, dropping any
type arguments.
Ref<T> is a generic type alias (index.d.ts:194:
type Ref<T> = RefCallback<T> | RefObject<T | null> | null;), so
Ref<HTMLDivElement> is emitted as React.Ref with no argument — invalid,
since
Ref` requires one type parameter. Observed 4 times in our bundle.

Defect 3 — KEEP_ALIAS is functionally half-dead (dts.mjs:231)

const KEEP_ALIAS = /^(ReactNode|ReactElement|CSSProperties|JSX\.Element|Key|Ref|RefObject)$/;

Of its seven entries, three are declared as interface in @types/react:
CSSProperties (index.d.ts:2477), ReactElement (index.d.ts:325), and
RefObject (index.d.ts:154). Interfaces never populate
getAliasSymbol(), so these three can never reach the :235 branch — they
always fall through to Defect 1 and emit bare. (ReactElement does so 3
times in our bundle.) JSX.Element contains a . and would not match a
bare getName() result either, so it is also dead weight in the list. That
leaves only ReactNode, Key, and Ref as types the regex actually
catches — and for Ref the catch itself produces invalid output via
Defect 2. Being listed in KEEP_ALIAS is not protection against this bug.

Why package-validate does not catch it

package-validate reported ok: true / "all .d.ts parse cleanly" on this
exact output on four separate occasions across three of our tickets. It
parses each declaration file in isolation — confirming it's syntactically
well-formed TypeScript — but never resolves the type references inside a
declaration against an import or ambient scope. A .d.ts file with a bare,
undeclared type name parses without error; it only fails when something
actually tries to typecheck against it. The pipeline's own quality gate is
structurally blind to this entire defect class.

Measured scope

One small 6-component library:

  • 9 emitted .d.ts files; 6 carry diagnostics (3 provider components are

clean)

  • 28 in-scope diagnostics: 24 unresolved-name, 4 generic-arity
  • 13 distinct dangling type names: CSSProperties, ReactElement,

JSXElementConstructor, SlotProps, ChipOwnerState, ChipClasses,
ChipSlots, ClassNameMap, SwitchClasses, SwitchSlots,
TouchRippleProps, Theme, CloseReason

  • Scope tracks how much foreign type surface a component inherits: a

component extending ChipProps emits 15 diagnostics; components
extending HTMLAttributes<HTMLDivElement> emit 1 each

CloseReason is the most damning individual case: it is our own exported
type, declared in the same package as the component that uses it. The
emitter resolves it correctly enough to use it in the generated JSDoc, but
emits it in the type position with no definition and no import — so a
component with zero third-party inheritance can still produce an invalid
declaration purely by exposing a locally-defined named type. This proves
the defect is not specific to React or to third-party types.

Two traps for whoever verifies a fix

Trap 1: skipLibCheck: true makes the check blind — but not by default.
This one is easy to get backwards, and we did at first. skipLibCheck
defaults to false, so a plain tsc --noEmit some.d.ts does check
the declaration and does report these errors. Verified:

ts.optionDeclarations.find(o => o.name === 'skipLibCheck').defaultValueDescription  →  false

tsc --noEmit probe.d.ts                 → TS2304 reported   (catches it)
tsc --noEmit --skipLibCheck probe.d.ts  → 0 diagnostics     (blind)

The blindness comes from skipLibCheck: true arriving via a tsconfig —
which is common, and is the case in the package we hit this on. Note also
that passing files positionally makes tsc ignore tsconfig.json entirely,
so a positional invocation is already safe and a --project-based one is
not. Pass --skipLibCheck false explicitly either way.

Whatever harness you build, prove it can fail: inject a type that cannot
possibly resolve (e.g. bogus?: DefinitelyNotARealType) and confirm it is
reported. A harness that silently checks nothing will certify the fix.

Two further notes on reading the output. Expect unrelated ambient @types
conflicts (we saw 6 from @types/css-font-loading-module against global DOM
types), so filter diagnostics by filename rather than reading the process
exit code. And --pretty defaults on for a TTY; pretty output does not
match a file(line,col): error TSxxxx: parse, so pass --pretty false
rather than relying on stdout being piped.

Trap 2: the diagnostic code is file-order-dependent, so naive
golden-file tests will flake.
The same 9 files produced 24 × TS2304, 4 ×
TS2314
when passed via a sorted find, but 21 × TS2304, 3 × TS2552, 4 ×
TS2314
when passed via xargs (different ordering). TS2552 is "Cannot
find name 'X'. Did you mean 'Y'?" — the spelling-suggestion heuristic
fires based on which symbols happen to be visible at that point in program
construction, which shifts with file order. The underlying defect is
identical in both runs; only the diagnostic code differs. We confirmed the
two invocations are byte-identical once normalized. A regression test must
fold TS2552 into TS2304 (or key assertions on the dangling type name
rather than the diagnostic code), or it will fail spuriously on unrelated
TypeScript version bumps or input ordering changes.

Suggested fix

  1. Apply a resolvable qualifier on every branch of typeText that can

return a type name — or, better, emit explicit named imports for the
aliases actually referenced, instead of relying on an ambient import *
as React
namespace being in scope. The import-based approach also
fixes non-React types (Theme, ChipClasses, and locally-defined types
like CloseReason), which no amount of React.-prefixing can reach.

  1. Preserve type arguments when returning the KEEP_ALIAS branch, so

Ref<T> round-trips as React.Ref<T> instead of React.Ref.

  1. Add an emitted-declaration type-resolution check as a

package-validate sub-step — actually typechecking the output, not just
parsing it — so this class of defect fails the pipeline instead of
passing it.

Regression cases worth pinning

  • An object-type alias that is actually an interface: CSSProperties

(never reaches KEEP_ALIAS because it has no alias symbol).

  • A generic alias: Ref<HTMLDivElement> (must keep its type argument).
  • A locally-defined, package-own exported type: CloseReason (proves the

bug fires even with zero third-party inheritance).

  • A non-React third-party type: Theme (proves the fix must not be

React-namespace-specific).

Why we cannot fix this downstream

The toolchain is vendored into consuming repos at <pkg>/.ds-sync/, and
that directory is gitignored and regenerated on every sync — any local
patch to dts.mjs is silently discarded the next time /design-sync runs.
Everything built on our side for this ticket (CAS-1765) is a detector that
flags the bad output after the fact; it cannot prevent the emitter from
producing it. An upstream fix in the toolchain source is the only durable
fix.

View original on GitHub ↗