Skip to content

Commit 3129f88

Browse files
committed
fix(scanner): narrow as const class properties to their literal type
A class field like `readonly provider = 'threads' as const` emitted `provider: unknown` in the generated .d.ts instead of `provider: 'threads'`. `inferLiteralType`/`inferTypeFromDefault` never stripped the `as const` suffix (so the string-literal check failed and fell through to `unknown`), and instance `readonly` fields weren't treated as const-like, so `as const` initializers were routed to the widening path. `inferLiteralType` now strips `as const` before inferring, and the class field path routes any `as const` initializer through it — so `as const` narrows to the literal type regardless of modifiers (matching tsc). Adds a regression test.
1 parent 5f27f58 commit 3129f88

2 files changed

Lines changed: 29 additions & 2 deletions

File tree

packages/dtsx/src/extractor/scanner.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1560,7 +1560,11 @@ export function scanDeclarations(_source: string, _filename: string, _keepCommen
15601560

15611561
/** Infer literal type from initializer value (for const-like / static readonly) */
15621562
function inferLiteralType(value: string): string {
1563-
const v = value.trim()
1563+
let v = value.trim()
1564+
// `x as const` narrows to the literal type of `x` — strip the assertion
1565+
// and infer from the underlying value (e.g. `'threads' as const` → "threads").
1566+
if (v.endsWith('as const'))
1567+
v = v.slice(0, -8).trim()
15641568
if (v === 'true' || v === 'false')
15651569
return v
15661570
if (isNumericLiteral(v))
@@ -2506,8 +2510,12 @@ export function scanDeclarations(_source: string, _filename: string, _keepCommen
25062510
type = asType
25072511
}
25082512
else {
2513+
// `as const` always narrows to the literal type (like tsc), no
2514+
// matter the modifiers; otherwise only static-readonly
2515+
// ("const-like") fields narrow and plain fields widen.
2516+
const isConstAssertion = initText.endsWith('as const')
25092517
const isConstLike = isStatic && isReadonly
2510-
type = isConstLike ? inferLiteralType(initText) : inferTypeFromDefault(initText)
2518+
type = (isConstAssertion || isConstLike) ? inferLiteralType(initText) : inferTypeFromDefault(initText)
25112519
}
25122520
}
25132521
else {

packages/dtsx/test/comprehensive.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,6 +1811,25 @@ describe('Class static members', () => {
18111811
expect(result).toContain('static readonly DEFAULT_TIMEOUT: number;')
18121812
expect(result).toContain('static instance: Config | null;')
18131813
})
1814+
1815+
it('should narrow `as const` class properties to literal types', () => {
1816+
const source = `
1817+
export class C {
1818+
readonly provider = 'threads' as const
1819+
static readonly apiVersion = '1.0' as const
1820+
readonly count = 42 as const
1821+
mutableConst = 'x' as const
1822+
}
1823+
`
1824+
const result = processSource(source)
1825+
// `as const` narrows to the literal type regardless of modifiers
1826+
// (previously these emitted `unknown`).
1827+
expect(result).toContain('readonly provider: \'threads\';')
1828+
expect(result).toContain('static readonly apiVersion: \'1.0\';')
1829+
expect(result).toContain('readonly count: 42;')
1830+
expect(result).toContain('mutableConst: \'x\';')
1831+
expect(result).not.toContain('unknown')
1832+
})
18141833
})
18151834

18161835
// ============================================================

0 commit comments

Comments
 (0)