ReviewOS

stacks/dtsx

Truncated declaration: a paren inside type arguments is read as an arrow function

#3107
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Summary

dtsx drives the TypeScript compiler API (ts.createSourceFile, used in packages/dtsx/src/parallel-processor.ts, utils.ts, circular.ts). TypeScript 7 is a Go port whose npm package no longer exposes that API — it exports only version and versionMajorMinor. So dtsx cannot run at all against TS 7.

Reproduction

Any invocation, in an environment where typescript@7 resolves:

SyntaxError: Export named 'createSourceFile' not found in module
  ~/.bun/install/cache/typescript@7.0.2@@@1/lib/version.cjs
const ts = await import('typescript')
;(ts.default ?? ts).version              // '7.0.2'
;(ts.default ?? ts).createSourceFile     // undefined
Object.keys(ts.default ?? ts)            // ['version', 'versionMajorMinor']

Why it bites even though the range says ^6.0.2

packages/dtsx/package.json declares "typescript": "^6.0.2", which 7.0.2 does not satisfy. But in a workspace that also depends on TypeScript, the hoisted copy is what an unscoped import('typescript') resolves to — in stacksjs/stx that is 7.0.2, and dtsx picks it up. The declared range does not protect the import.

Downstream effect

stacksjs/stx ships declarations generated by dtsx, and two of them are currently syntactically invalid — truncated mid-signature:

// dist/composables.d.ts
export declare const RouterKey: (createInjectionKey<{
  currentRoute: string
  navigate: (path: string)) => unknown;

dist/composables.d.ts has 4 parse errors and dist/story/cli.d.ts has 10. dist/ is published, and a syntax error in a .d.ts is not suppressible with skipLibCheck (that only silences semantic diagnostics), so tsc aborts on any consumer program that pulls the file in — reporting nothing about the consumer's own code. Filed as stacksjs/stx#1888; stx now detects and re-emits these at build time.

I want to be precise about attribution: I could not confirm which dtsx code path produced that truncation, because dtsx does not run in this environment at all (the error above). The affected shape is consistent — a function-typed property nested inside a type argument:

// truncates
export const RouterKey = createInjectionKey<{
  currentRoute: string
  navigate: (path: string) => void
}>('router')

// same call, no function-typed property — emits correctly
export const ThemeKey = createInjectionKey<{
  mode: 'light' | 'dark'
}>('theme')

That may be a pre-existing bug from an older run rather than a TS-7 symptom. Both are worth checking.

Ask

  1. Decide dtsx's stance on TypeScript 7. It removed the JS compiler API, so this is not a version bump — it needs either a pinned TS ≤6 that dtsx resolves itself rather than through hoisting, or a different parser. oxc-parser is already a declared dependency but is not imported anywhere under packages/dtsx/src.
  2. Fail loudly on an incompatible TypeScript instead of throwing Export named 'createSourceFile' not found from deep inside a module load.
  3. Check the truncation shape above independently of (1) — a function type nested in a type argument, emitted as (param: T)) => unknown;.
8 days ago

Correction: this is not a TypeScript 7 problem, and the original title was wrong

I filed this blaming the TS 7 compiler-API removal. That attribution was wrong, and I want to correct the record rather than leave it standing — the original framing would send someone to change their TypeScript version, which fixes nothing here. I've retitled the issue accordingly.

dtsx runs fine under TypeScript 7. The truncation reproduces in two lines with no build and no version games:

const m = await import('@stacksjs/dtsx')
await m.processSource(`export const A = k<{ go: (p: string) => void }>('a')`, '/tmp/p.ts')
// export declare const A: (k<{ go: (p: string)) => unknown;

The actual cause

The trigger was never function-typed properties — it's any paren inside the type arguments, including empty ones:

sourcebefore
k<{ go: string }>('b')✅ ReturnType<typeof k<{ go: string }>>
k<{ x: string }>(String(1))✅ parens in the value args were always fine
k<{ go: (p: string) => void }>('a')❌ (k<{ go: (p: string)) => unknown
k<{ go: () => void }>('c')❌ empty parens too
k<(p: string) => void>('d')❌ (k<(p: string)) => boolean
k<Array<(p: string) => void>>('f')❌ same

Two scanners in processor/type-inference.ts walked past the type arguments as plain text:

  • findMainArrowIndex deliberately doesn't track </> (comparison-operator ambiguity), so the => of a property type read as the value's main arrow — and inferNarrowType dispatched to inferFunctionType before inferCallType was ever consulted.
  • inferCallType took indexOf('('), which lands inside the type argument, so the call looked unbalanced and it bailed to that same fallback.

Fixed in 86c8858: both now skip balanced <...> runs only, so an unbalanced < still scans through as the comparison operator it is. Regression tests in packages/dtsx/test/issue-3107.test.ts — the two repro tests fail without the fix, and the four guards (generic arrow functions, real arrows, comparisons, previously-working shapes) pass both ways. Full suite unchanged at 18 pre-existing failures.

Verified end-to-end against the file that surfaced this, stx/packages/stx/src/composables.ts, which now emits complete and parses with zero syntax errors:

export declare const RouterKey: ReturnType<typeof createInjectionKey<{
  currentRoute: string
  navigate: (path: string) => void
}>>;

What was right, with corrected scope

The SyntaxError: Export named 'createSourceFile' not found is real, but it is not "dtsx cannot run at all" and it did not cause the truncation. Under Bun's CJS interop a default import (import ts from 'typescript') yields an object whose members are simply undefined, so checker.ts links and only fails if it calls one. A named import is a hard link error, and five modules use them:

extractor/cache.ts, extractor/helpers.ts, extractor/index.ts,
extractor/declarations.ts, extractor/builders.ts

import('@stacksjs/dtsx') and processSource don't pull those in, which is why the package works. Importing extractor/cache.ts directly throws immediately. That's a separate, still-open defect — filed on its own so it doesn't ride along with a fix that has nothing to do with it.

Sign in to comment on this issue.