also looking at this
fix(inference): stop reading a conditional with arrow branches as one arrow function
#3132
2 files
+71
-1
| @@ -312,7 +312,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio | ||
| 312 | 312 | // Function expressions. Check these after `as const` so an asserted |
| 313 | 313 | // object or array containing arrow-function properties is inferred as a |
| 314 | 314 | // container instead of treating the complete initializer as one function. |
| 315 | if (findMainArrowIndex(trimmed) !== -1 || trimmed.startsWith('function') || trimmed.startsWith('async')) { | |
| 315 | if (isArrowFunction(trimmed) || trimmed.startsWith('function') || trimmed.startsWith('async')) { | |
| 316 | 316 | return inferFunctionType(trimmed, inUnion, _depth, isConst) |
| 317 | 317 | } |
| 318 | 318 | |
| @@ -2403,6 +2403,28 @@ function findMainArrowIndex(str: string): number { | ||
| 2403 | 2403 | return -1 |
| 2404 | 2404 | } |
| 2405 | 2405 | |
| 2406 | /** | |
| 2407 | * Whether the main arrow of `str` can be that of an arrow function, whose | |
| 2408 | * parameters come before it: a parenthesized list or one name. In | |
| 2409 | * `c ? (v: string) => v : …` or `{ f: () => 1 } as object` the text before the | |
| 2410 | * arrow is neither, and the arrow belongs to a nested function. | |
| 2411 | */ | |
| 2412 | function isArrowFunction(str: string): boolean { | |
| 2413 | const arrow = findMainArrowIndex(str) | |
| 2414 | if (arrow === -1) return false | |
| 2415 | let head = str.slice(0, arrow).trim() | |
| 2416 | if (head.length > 5 && head.startsWith('async') && !isWordChar(head.charCodeAt(5))) head = head.slice(5).trim() | |
| 2417 | if (head.startsWith('<')) { | |
| 2418 | const end = findMatchingBracket(head, 0, '<', '>') | |
| 2419 | if (end === -1) return true | |
| 2420 | head = head.slice(end + 1).trim() | |
| 2421 | } | |
| 2422 | // A parameter list, or a comment before one. | |
| 2423 | if (head.startsWith('(') || head.startsWith('/')) return true | |
| 2424 | // One parameter name, possibly followed by a comment. | |
| 2425 | return /^(?:[\w$\u0080-\uFFFF]|\\u[\da-f]{4}|\\u\{[\da-f]+\})+$/i.test(head.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n\r]*/g, '').trim()) | |
| 2426 | } | |
| 2427 | ||
| 2406 | 2428 | /** |
| 2407 | 2429 | * Infer function type from function expression |
| 2408 | 2430 | */ |
| @@ -0,0 +1,48 @@ | ||
| 1 | /** | |
| 2 | * A conditional whose branches are arrow functions was read as one arrow: | |
| 3 | * | |
| 4 | * export const toPosix = isWin ? (value: string) => value.replace(...) : (value: string) => value | |
| 5 | * | |
| 6 | * emitted `toPosix: (isWin ? (value: string)) => any`, which does not parse. | |
| 7 | * The arrow found outside parentheses belongs to a branch, and everything | |
| 8 | * before it was taken as the parameter list. Pinia's `getActivePinia` and | |
| 9 | * knip's `toPosix` and `debugLog` are written this way. Such a value is now | |
| 10 | * typed as other conditionals are, `unknown`; arrow functions read as before. | |
| 11 | */ | |
| 12 | import { describe, expect, it } from 'bun:test' | |
| 13 | import { processCode } from './test-utils' | |
| 14 | ||
| 15 | const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' }) | |
| 16 | ||
| 17 | describe('conditional with arrow function branches', () => { | |
| 18 | it.each([ | |
| 19 | ['both branches arrows', 'declare const isWin: boolean\nexport const toPosix = isWin ? (value: string) => value.split(\'\\\\\').join(\'/\') : (value: string) => value\n', 'toPosix: unknown;'], | |
| 20 | ['branches with return types, over several lines', 'declare const dev: boolean\nexport const getActive = dev\n ? (): string | undefined => {\n return undefined\n }\n : (): string | undefined => undefined\n', 'getActive: unknown;'], | |
| 21 | ['an arrow and a name', 'declare const enabled: boolean\ndeclare const noop: () => void\nexport const log = enabled\n ? (context: string, message: string) => console.log(context, message)\n : noop\n', 'log: unknown;'], | |
| 22 | ['an arrow in the second branch only', 'declare const c: boolean\nexport const pick = c ? 1 : (v: string) => v\n', 'pick: unknown;'], | |
| 23 | ['an object property', 'declare const c: boolean\nexport const handlers = { a: c ? () => 1 : () => 2, b: (x: number) => x }\n', 'a: unknown;\n b: (x: number) => number'], | |
| 24 | ['an array element', 'declare const c: boolean\nexport const list = [c ? () => 1 : () => 2]\n', 'list: unknown[];'], | |
| 25 | ['an asserted object holding an arrow', 'export const routes = { home: { render: () => 1 } as object }\n', 'home: unknown'], | |
| 26 | ])('emits a type that parses: %s', (_label, code, expected) => { | |
| 27 | const output = processCode(code) | |
| 28 | expect(() => transpiler.transformSync(output)).not.toThrow() | |
| 29 | expect(output).toContain(expected) | |
| 30 | }) | |
| 31 | ||
| 32 | it.each([ | |
| 33 | ['a parenthesis in a default', 'export const q = (a = \')\') => a\n', 'q: (a?: string) => string;'], | |
| 34 | ['a regular expression with parentheses in a default', 'export const re = (x: RegExp = /[)(]/): RegExp => x\n', 're: (x?: RegExp) => RegExp;'], | |
| 35 | ['a nested template literal in a default', 'export const text = (x: string = `${`)(`}`): string => x\n', 'text: (x?: string) => string;'], | |
| 36 | ['a comment after the parameters', 'export const fn = (x: number) /* note */ => x\n', 'fn: (x: number) => number;'], | |
| 37 | ['type parameters and a return type', 'export const id = <T,>(a: T): T => a\n', 'id: <T,>(a: T) => T;'], | |
| 38 | ['a type predicate', 'export const isX = (a: string): a is \'x\' => a === \'x\'\n', 'isX: (a: string) => a is \'x\';'], | |
| 39 | ['one bare parameter', 'export const same = x => x\n', 'same: (x) => unknown;'], | |
| 40 | ['one bare parameter and a comment', 'export const noted = x /* note */ => x\n', 'noted: (x /* note */) => unknown;'], | |
| 41 | ['one non-ASCII parameter', 'export const greek = αβ => αβ\n', 'greek: (αβ) => unknown;'], | |
| 42 | ['one escaped parameter', 'export const esc = \\u0078 => \\u0078\n', 'esc: (\\u0078) => unknown;'], | |
| 43 | ['one parameter with a code point escape', 'export const point = \\u{78} => \\u{78}\n', 'point: (\\u{78}) => unknown;'], | |
| 44 | ['async', 'export const load = async (a: string) => a\n', 'load: (a: string) => Promise<unknown>;'], | |
| 45 | ])('still reads an arrow function: %s', (_label, code, expected) => { | |
| 46 | expect(processCode(code)).toContain(expected) | |
| 47 | }) | |
| 48 | }) | |