ReviewOS

stacks/bun-plugin-dtsx

Arrow type inside a generic argument emits invalid .d.ts (unbalanced parens, TS1005)

#4108
Open glennmichael123 opened this 8 days ago ·
8 days ago

Verified against bun-plugin-dtsx@0.21.17, emitting with Bun 1.4.0-canary.1+5b98630ac.

Symptom

An exported const whose initializer contains an arrow type inside a generic type argument emits syntactically invalid TypeScript. tsc rejects the generated .d.ts with TS1005.

Minimal reproduction

src/index.ts:

export function key<T>(name: string): symbol & { __t?: T } {
  return Symbol(name) as symbol & { __t?: T }
}

/** A: generic argument with NO arrow type */
export const NoArrow = key<{ accent?: string }>('a')

/** B: generic argument CONTAINING an arrow type */
export const WithArrow = key<{ navigate: (path: string) => void }>('b')

/** C: two arrows in one generic argument */
export const TwoArrows = key<{ f: (x: string) => void, g: (y: number) => string }>('c')

build.ts:

import { dts } from 'bun-plugin-dtsx'
await Bun.build({ entrypoints: ['./src/index.ts'], outdir: './dist', root: './src', plugins: [dts()] })

Emitted dist/index.d.ts

export declare function key<T>(name: string): symbol & { __t?: T };
/** A: generic argument with NO arrow type */
export declare const NoArrow: unknown;
/** B: generic argument CONTAINING an arrow type */
export declare const WithArrow: (key<{ navigate: (path: string)) => unknown;
/** C: two arrows in one generic argument */
export declare const TwoArrows: (key<{ f: (x: string)) => any;
$ tsc --noEmit --skipLibCheck false dist/index.d.ts
dist/index.d.ts(5,64): error TS1005: '=>' expected.
dist/index.d.ts(5,66): error TS1005: ';' expected.
dist/index.d.ts(7,54): error TS1005: '=>' expected.
dist/index.d.ts(7,56): error TS1005: ';' expected.

What appears to be happening

NoArrow — no arrow anywhere in the initializer — widens to unknown correctly, which is the expected behaviour for a syntactic emitter that cannot resolve the type.

When an arrow is present, the emitter appears to treat the initializer as an arrow function: it opens a (, scans for the closing ), and takes the first one it finds rather than balancing. That first ) belongs to the inner arrow's parameter list, so the declaration is cut mid-type and the remainder of the generic argument (plus the real call arguments) is dropped, leaving unbalanced ( and {.

Note case C also shows the trailing type varying with the first inner arrow's return type — => unknown for void, => any for string — which is consistent with the inner arrow being mistaken for the outer one.

Where this ships today

@stacksjs/stx ships a broken declaration built by this plugin:

packages/stx/dist/composables.d.ts:227-234, from packages/stx/src/composables.ts:954-963:

export declare const RouterKey: (createInjectionKey<{
  currentRoute: string
  navigate: (path: string)) => unknown;
export declare const I18nKey: (createInjectionKey<{
  locale: string
  t: (key: string)) => unknown;

Four TS1005 errors. This is present in the published @stacksjs/stx@0.2.152 from npm as well as in a local build, so it is not specific to a canary Bun or to one machine.

Scope in that package is narrow — 1 of 166 emitted .d.ts files — and it is currently latent rather than breaking consumers: the package's exports map points ./composables at the directory dist/composables/index.d.ts, nothing imports the bare ./composables specifier (which would resolve to the broken flat file, since TS prefers a file over a directory index), and most consumers run skipLibCheck: true. It would begin to bite on skipLibCheck: false, or if that resolution ever changes.

Suggested fix

Balance parentheses (and braces/angle brackets) when scanning an initializer for an arrow-function signature, rather than stopping at the first ). Failing that, falling back to unknown — as already happens for the no-arrow case — is strictly better than emitting output that does not parse.

  • stacksjs/stx#1796 — the same emitter falling back to : void for inferred return types. Different failure mode: that one emits valid TypeScript with the wrong type; this one emits TypeScript that does not parse.

Sign in to comment on this issue.