ReviewOS

also looking at this

stacks/dtsx

fix(inference): stop reading a conditional with arrow branches as one arrow function

#3132
Open mkantautas wants to merge fix/ternary-arrow-type into main
2 files +71 -1
packages/dtsx/src/processor/type-inference.tsmodified+23-1
Changes to packages/dtsx/src/processor/type-inference.ts
@@ -312,7 +312,7 @@ export function inferNarrowType(value: unknown, isConst: boolean = false, inUnio
312312 // Function expressions. Check these after `as const` so an asserted
313313 // object or array containing arrow-function properties is inferred as a
314314 // 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')) {
316316 return inferFunctionType(trimmed, inUnion, _depth, isConst)
317317 }
318318
@@ -2403,6 +2403,28 @@ function findMainArrowIndex(str: string): number {
24032403 return -1
24042404}
24052405
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 */
2412function 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
24062428/**
24072429 * Infer function type from function expression
24082430 */
packages/dtsx/test/ternary-arrow-type.test.tsadded+48-0
Changes to packages/dtsx/test/ternary-arrow-type.test.ts
@@ -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 */
12import { describe, expect, it } from 'bun:test'
13import { processCode } from './test-utils'
14
15const transpiler = new Bun.Transpiler({ loader: 'ts', target: 'bun' })
16
17describe('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})