ReviewOS

also looking at this

stacks/dtsx

fix(inference): keep the type parameters of a named function expression

#3130
Open mkantautas wants to merge fix/generic-function-expression into main
2 files +96 -1

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

packages/dtsx/src/processor/type-inference.tsmodified+18-1
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -2577,8 +2577,17 @@ export function inferFunctionType(value: string, inUnion: boolean = false, _dept
25772577 while (pos < len && trimmed.charCodeAt(pos) <= 32) pos++
25782578 }
25792579
2580 // Check for generics <...>
2580 // Check for generics <...>, which follow the name if there is one:
2581 // `function <T>(a: T)`, `function id<T>(a: T)`. `functionFactory<T>()`
2582 // is a call, not a function expression with a name.
25812583 let generics = ''
2584 let nameStart = -1
2585 if (!isWordChar(trimmed.charCodeAt(8))) {
2586 const start = pos
2587 while (pos < len && isWordChar(trimmed.charCodeAt(pos))) pos++
2588 if (pos > start) nameStart = start
2589 while (pos < len && trimmed.charCodeAt(pos) <= 32) pos++
2590 }
25822591 if (pos < len && trimmed.charCodeAt(pos) === 60) { // <
25832592 const genStart = pos
25842593 let depth = 1
@@ -2593,6 +2602,14 @@ export function inferFunctionType(value: string, inUnion: boolean = false, _dept
25932602 generics = trimmed.substring(genStart, pos)
25942603 while (pos < len && trimmed.charCodeAt(pos) <= 32) pos++
25952604 }
2605 // A named function's type parameters are kept only when the loop above
2606 // closed them and they hold no quote or comment, which it does not skip
2607 // (`<T extends '>'>`), and no `typeof`, which may name the function or
2608 // its locals (`<T = typeof id>`); otherwise they are left out as before.
2609 if (nameStart !== -1 && generics && (!generics.endsWith('>') || /['"`/]|\btypeof\b/.test(generics))) {
2610 generics = ''
2611 pos = nameStart
2612 }
25962613
25972614 // Skip optional function name until (
25982615 const parenIdx = trimmed.indexOf('(', pos)
packages/dtsx/test/generic-function-expression.test.tsadded+78-0
Changes to packages/dtsx/test/generic-function-expression.test.ts
@@ -0,0 +1,78 @@
1/**
2 * A named function expression lost its type parameters:
3 *
4 * export const identity = function id<T>(value: T): T { return value }
5 *
6 * emitted `(value: T) => T`, where `T` is not declared (TS2304). The parser
7 * looked for type parameters right after `function`, where an anonymous
8 * function expression has them, not after the name.
9 */
10import { describe, expect, it } from 'bun:test'
11import { mkdtemp, rm, writeFile } from 'node:fs/promises'
12import { tmpdir } from 'node:os'
13import { join } from 'node:path'
14import { runTsc } from './helpers/tsc'
15import { processCode } from './test-utils'
16
17/** Type-check `consumer` against the generated declarations as `./index`. */
18async function checkConsumer(dts: string, consumer: string): Promise<string> {
19 const dir = await mkdtemp(join(tmpdir(), 'dtsx-generic-function-expression-'))
20 try {
21 await writeFile(join(dir, 'index.d.ts'), dts)
22 await writeFile(join(dir, 'use.ts'), consumer)
23 return runTsc(dir, ['index.d.ts', 'use.ts']).output
24 }
25 finally {
26 await rm(dir, { recursive: true, force: true })
27 }
28}
29
30describe('function expressions with type parameters', () => {
31 it.each([
32 [
33 'named',
34 'export const identity = function id<T>(value: T): T { return value }\n',
35 'identity: <T>(value: T) => T;',
36 'import { identity } from \'./index\'\nconst n: number = identity(1)\nexport {}\n',
37 ],
38 [
39 'named, with a constraint and a default',
40 'export const pick = function pick<K extends string, V = number>(key: K, value: V): V { return value }\n',
41 'pick: <K extends string, V = number>(key: K, value: V) => V;',
42 'import { pick } from \'./index\'\nconst s: string = pick(\'a\', \'b\')\nexport {}\n',
43 ],
44 [
45 'named generator',
46 'export const repeat = function* repeat<T>(value: T): Generator<T> { yield value }\n',
47 'repeat: <T>(value: T) => Generator<T>;',
48 'import { repeat } from \'./index\'\nconst g: Generator<number> = repeat(1)\nexport {}\n',
49 ],
50 [
51 'anonymous',
52 'export const anon = function <T>(value: T): T { return value }\n',
53 'anon: <T>(value: T) => T;',
54 'import { anon } from \'./index\'\nconst n: number = anon(1)\nexport {}\n',
55 ],
56 [
57 'named, with a space before them',
58 'export const spaced = function id <T>(value: T): T { return value }\n',
59 'spaced: <T>(value: T) => T;',
60 'import { spaced } from \'./index\'\nconst n: number = spaced(1)\nexport {}\n',
61 ],
62 ])('keeps them: %s', async (_label, code, signature, consumer) => {
63 const output = processCode(code)
64 expect(output).toContain(signature)
65 expect(await checkConsumer(output, consumer)).toBe('')
66 })
67
68 it.each([
69 ['a quoted `>` in a constraint', 'export const f = function id<T extends \">\">(value: number): number { return value }\n', 'f: (value: number) => number;'],
70 ['a quoted `<` in a constraint', 'export const f = function id<T extends \"<\">(value: number): number { return value }\n', 'f: (value: number) => number;'],
71 ['a quoted `>` after a space', 'export const f = function id <T extends \'>\'>(value: number): number { return value }\n', 'f: (value: number) => number;'],
72 ['a comment holding `>`', 'export const f = function id<T /* values > 0 */>(value: number): number { return value }\n', 'f: (value: number) => number;'],
73 ['a type parameter naming the function itself', 'export const f = function id<T = typeof id>(value: number): number { return value }\n', 'f: (value: number) => number;'],
74 ['a call to a function whose name starts with `function`', 'declare function functionFactory<T>(): () => unknown\nexport const f = functionFactory<string>()\n', 'f: () => unknown;'],
75 ])('reads as before: %s', (_label, code, signature) => {
76 expect(processCode(code)).toContain(signature)
77 })
78})