Repository navigation
Conversation
…ignments
In a TypeScript file, `a ? b => (c) : d => e` failed with `Expected ":" but
found ";"`, and `a ? (b): T => (c) : d => e` printed
`a ? b : (T) => (c) => e`. tsc accepts both.
The mark that `parse_paren_expr` reads between "?" and ":" now reaches the
body of an arrow function and the right side of an assignment, as
`allowReturnTypeInArrowFunction` does in tsc. Parentheses that tsc takes as
parameters at first sight ("()", "(...a", "(a:", "(a?") start a new
expression in their body.
|
Updated 9:35 AM PT - Oct 28th, 2026
❌ @robobun, your commit 6f381e3 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47133That installs a local version of the PR into your bun-47133 --bun |
|
Status How to reproduce on main (7942008):
const byId = false;
const pick = byId ? id => ({ id }) : name => ({ name });
console.log(JSON.stringify(pick("a")));
const next = byId ? (n): number => (n) : n => n + 1;
console.log(typeof next, next(1));Run The test: The branch is on CI on the head 6f381e3 (build 134413): 177 jobs passed. The four darwin PR: #47133 |
|
A second branch for the same two lines exists: The two parsers print the same for 405,464 generated conditionals (tsc 6.0.2 as the reference). The branch differs in three places. Each was checked on a release build with the parser of 5e78a89.
The branch also has a test that compares 2,268 inputs (18 heads, 7 tails, 22 positions) with the Two facts for the description:
|
…nditional-arrow-bodies-2
…(a: A)` as on main
- In an arrow function body or on the right side of an assignment between
"?" and ":", `async()` before a ":" is a call only if no reading
leaves a ":" for the conditional. `a ? q => async (): T => (d) : e => f`
stays the error that it is on main.
- Behind type parameters, parentheses that can only be parameters take
no attempt. An error in `a ? <T>(b: B): U => { ... } : c` is reported
where it is.
…f the generic impl Neither reads `TYPESCRIPT` or `SCAN_ONLY`. As methods of `P` they were compiled once per parser copy.
Depends on #45743, #45939 and #46209 (the base branch holds them). Draft until they have a decision.
Problem
bun -e 'const f = 1 ? v => ({ v }) : v => 0'fails withExpected ":" but found ";". It is legal JavaScript (bun -eand stdin use the TypeScript parser). In a.tsfile,byId ? (n): number => (n) : n => n + 1printsbyId ? n : (number) => (n) => n + 1with no error. tsc and Node 26 take both.?and:,(c) : d => eis an arrow function only if a second:follows (parser: accept TypeScript arrow return types inside conditionals,export typebefore a newline, andimport type from#40818). The mark for that,EFlags::AfterQuestionAndBeforeColon, stops at the first token of the branch: the body ofv =>and the right side ofq =do not get it.Fix
parse_arrow_bodyand the 16 assignment operators pass the mark on, as tsc passesallowReturnTypeInArrowFunction.(),(...a,(a:and(a?are parameters at first sight, and their body is a new expression.test/bundler/transpiler/transpiler.test.js: 12 tests fail on the base. Two compare 4,000 generated conditionals withtypescript.Background
a ? (b) : c => dis ambiguous:(b): c => dcan be an arrow function with the return typec. The parser tries that on a snapshot (the attempt) and keeps it if a:follows.name =>(one shape of four). No first sight rule: accepted inputs print another program.Downsides
a ? (b) : c => (d): T => eis nowa ? (b) => d : (T) => e, nota ? b : (c) => (d) => e.bun fmt --arrow-parens=avoidwrites such text.a ? (b) : c => async (d): Promise<T> => e.a ? q => (p): T => … : etakes 330 ms and 405 MB (base: 12 ms, 33 MB). Other.tsstatements: up to 109 more instructions..tsnests 2.7% less deep.Notes
Where this comes from
A fuzz-ledger note, relayed through a maintainer's account: two programs that tsc accepts. This PR is the first. The second is #45743. There is no GitHub issue and no user report for Bun. biomejs/biome#9548 reports the first line of the Problem for another tool, with code from a project:
isFirstMount ? i => ({ [CONTENT_SLOT]: i }) : i => wrapSlotExpr(newExprs[i]). Main refuses that code in a.tsfile too.The trigger is narrow. A text that can be parameters (
(c),({ c }),(c, d),(c = d),([c])) stands directly before the:as an arrow function body, as the right side of an assignment or as a nested false branch. And the false branch starts with a parameter without parentheses:name =>. With(name) =>, main already reads both lines of the Problem as tsc does. The transpiled output of 3,904 TypeScript and 7,999 JavaScript files of this repo is the same before and after.The assignments,
async, type parameters and the first sight rule are not in the note. They are the rest of the same tsc rule, found with the generated inputs below.Inputs
x = byId ? id => ({ id }) : name => ({ name });Expected ":" but found ";"x = byId ? (id) => ({ id }) : (name) => ({ name });x = byId ? (n): number => (n) : n => n + 1;x = byId ? n : (number) => (n) => n + 1;, no errorx = byId ? (n) => n : (n) => n + 1;x = a ? q = (c) : d => e;Expected ":" but found ";"x = a ? q = c : (d) => e;x = a ? (b): T => q = (c) : d => e;x = a ? b : (T) => q = (c) => e;, no errorx = a ? (b) => q = c : (d) => e;x = a ? <T>(b) : c => d;Expected ":" but found ";"x = a ? b : (c) => d;x = a ? b => async() : c => d;Expected ":" but found ";"x = a ? (b) => async() : (c) => d;':' expected.x = a ? (b) : c => () => (d) : e => f;x = a ? b : (c) => () => (d) => f;x = a ? (b) : c => (d): T => e;x = a ? b : (c) => (d) => e;x = a ? (b) => d : (T) => e;x = a ? (b) : c => async (d): Promise<T> => e;x = a ? b : (c) => async (d) => e;Expected ";" but found "=>"';' expected.x = a ? () => (b) : c => d;Expected ":" but found ";"':' expected.TypeScript has ten tests for this rule,
parserArrowFunctionExpression8to17. Main prints tsc's tree for seven of them and this PR for ten. A test holds the ten lines.The rule
tsc hands
allowReturnTypeInArrowFunctiondown where an expression continues the branch: into an arrow function body, into the right side of an assignment and into the false branch of a nested conditional. It starts again withtrueinside parentheses, brackets, call arguments and a block body.EFlagsis that flag here. It has three marks.AfterQuestionAndBeforeColon: directly between?and:, as on main.(c) : d => eis an arrow function only if the attempt finds a:behind its body. A callasync(...)stays a call if the attempt fails.AfterQuestionAndBeforeColonCarried: the same in an arrow function body or on the right side of an assignment. Hereasync()before a:is a call only if no reading leaves a:for the conditional.a ? q => async() : d => eis a call.a ? q => async (): T => (d) : e => fhas such a reading, so it stays the error that it is on main and in tsc 6.0.AfterQuestionAndBeforeColonInTypedArrow: the same, in the body of an arrow function whose own return type is being tried. An attempt around it waits for a:, so this state follows tsc exactly:(),(...a,(a:and(a?start an arrow function at once, also behindasync.A head that tsc decides at first sight (
isParenthesizedArrowFunctionExpressionreturnsTristate.True) has a new expression as its body: the mark stops there.a ? (b) : c => () => (d) : e => fneeds that. tsc reads() => (d): e => fas the body ofc =>, so no:is left for(b). With the mark in the body of() =>,(d)ends the body and(b): c => () => (d)becomes an arrow function.<T>(andasync <T>(get the mark too. tsc never decides them at first sight.<T>(b)can be a cast, so it needs the attempt.<T>(b: B),<T>()and<T>(...b)are no cast: they take no attempt, as on main, so an error in their body is reported where it is. In.tsx,<T,>(is decided at first sight, so it gets no mark.The memo of attempts is keyed by the position of the
:, byallow_awaitand now by whether the body is read under a mark. The same:can be reached with a first sight head and with another head.What a build without the first sight rule does
On an earlier build of this change, with the rule switched off: of 150,000 generated
.tsconditionals, 418 that tsc accepts printed another program and 67 failed. With the rule: none. The tests under "Such a body takes ..." fail without it.Observable changes
Counted on 360,000 generated conditionals (300,000
.ts, 60,000.tsx) with release builds of the base and this PR. The false branch starts with a barename =>in more than half of them. tsc 6.0.2 accepts 206,076.a ? async ({ b }) : (b: B = c) => d => c : e. tsc's parser takes that and its checker rejects it. The type skipper here does not take it. That is not part of this change.async, which stays a call in an arrow function body as main keepsa ? async() : 0. tsc 6.0.2 rejects eachasync()before a:.:to the parentheses, and what is left is not a false branch:a ? (b) : c => (d): T[] => e,a ? (b) : c => async (d): Promise<T> => e,a ? (b) : c => (d, e?): T => f. Main readsc => ...as the false branch.With the
jsloader, the 300,000.tsinputs give the same output or error message as before.Two more changes on inputs that tsc rejects:
Unexpected =>fora ? (b): => c : d, because the attempt reads the type again with errors on. The body ofq =>had no attempt, so main printsa ? q => (b): => c : das an arrow function. Now it is an error there too.bun fmt --arrow-parens=avoid, the setting of this repo, writesa ? (b = 1) : c => (d): number => efroma ? b = 1 : (c) => (d): number => e. Prettier 3.9.9 writes the same. tsc and Node 26 read the written text as this PR does:a ? ((b = 1): c => (d)) : (number => e). Main reads it as the text before the formatter. The formatter is the defect there.Known gaps
awaitas a name before/, inside parentheses in the body ofa ? async (h, k) : R =>. tsc reads such parentheses as parameters first, whereawaitis a name and/divides. The attempt reads an expression, whereawait /x.../is a regular expression, so the parentheses end at another place.a ? () => (b) : c => dstays an error, as in tsc 6.0.2 and on main:()starts an arrow function at first sight, and its body is a new expression. It is legal JavaScript.a ? async() : 0. Its test has 85 lines. This PR reads 54 as that fix does (main: 44). A test holds 38 of them. 16 are errors here, as in tsc 6.0.2. 11 print another tree, 10 of them as on main:a ? async() : T => async (): T => 0keeps the call. 4 are error recovery lines.Cost of the attempt
An attempt parses the return type and the body on a snapshot, restores, and the caller parses them again. What a discarded attempt allocated in the arena stays until the file is done. Main has this cost at the first token of a branch. This PR has it in arrow function bodies, on the right side of assignments and behind
<T>too.One
transformSyncper process, release builds, peak RSS. An idle process is about 30 MB.a ? q => (p): T => … : ea ? q => (p) : c => …a ? q = (p): T => … : ea ? q = (p) : c => …a ? <T>(p): T => … : ea ? (p = …): T => p : ea ? (b) : c => … : ea ? (b) : c => …640 levels of the first row are 14.7 KB. The times are single runs and move by a factor of two. The memory is stable and grows four times per doubling. Each attempt runs once: the tests have chains of 40, which 2^40 parses would not finish. There is no cap on depth or on attempts.
#46311 (draft) keeps the arrow function that an attempt finds, so a kept attempt is not parsed again. It does not change the rows with a discarded attempt.
Measurements
Release builds from clean trees, linux x64: the base 508fab2 (main 7942008 with #45743, #45939 and #46209) and 86460ad. The head 6f381e3 moves two helpers out of the generic
impl. A release build of it has the same.textsize, the same frames and the same result on the 360,000 generated inputs.transformSynccall, per statement of a file with 20 statements (single-stepped, JIT off, median of 3 runs).x = a;-12 of 6,016.x = (a);-20.x = a => a;-38.x = a ? b => c : d => e;-74.x = a;+7 of 6,451.x = (a);+7.x = a => a;-5.x = (a: T): U => a;-1.x = <T[]>(a);+7.x = f<T>(a);+9.x = { a: (b), c: [d, (e)] };+13.?and::x = a ? b : c;+11 of 9,723.x = a ? b = c : d;+25.x = a ? (b) => c : d;+94 of 16,970.x = a ? (b) : c => d : e;+100.x = a ? (b: B): T => c : d;+84.x = a ? (b) : (c);+109 of 12,849.x = a ? b => c : d => e;-4.x = a ? b => (c): T => d : e;+1,895 of 21,390. The body ofb =>has an attempt now.parse_paren_exprin TypeScript: 1,088 B on main, 496 B on the base, 528 B with this PR (the scan-only copy: 528 to 544 B). JavaScript: 448 B on all three.parse_arrow_body: 192 to 176 B in JavaScript, 192 B in TypeScript.parse_suffix: 240 to 224 B in one copy and 224 to 240 B in another.try_skip_type_script_arrow_return_type_with_backtrackingis#[inline(never)]now. Without that, LLVM inlined it intoparse_paren_exprfor this change, with its lexer snapshot: 752 B per frame and 18% less nesting.((((1))))that parses: 7,031 to 6,844 in.ts(2.7% less), 7,333 to 7,338 in.js.() => () => ... 1: 6,039 to 5,900 in.ts, 6,260 to 6,341 in.js. At a 1 MB stack in.ts: 750 to 732..text83,513,897 to 83,515,177 B (+1,280). Parser symbols (nm -S): +1,315 B.Tests
.tsx, everyBun.TranspilerAPI, a spawned.tsfile andbun -e,emitDecoratorMetadatabehind a kept and a discarded attempt, and what a discarded attempt must leave behind.parserArrowFunctionExpression8to17.typescriptas oracle, for.tsand.tsx: 2,000 statements each from a generator with the seed 11. On main 247 and 150 of them differ from tsc. They are skipped on debug and ASAN builds, where loading TypeScript takes about a minute. The oracle test of parser: a:after(b as B)or<B[]>(b)is not an arrow function return type (TypeScript) #46209 gets a plainbitem: 34 of its inputs differ on the base.transpiler.test.js(279 pass),test/bundler/esbuild/ts.test.ts(59),test/cli/run/transpiler-cache.test.ts(24),runtime-transpiler.test.ts(15),decorators.test.ts(25),decorator-metadata.test.ts(5),es-decorators.test.ts(427),es-decorators-esbuild.test.ts(146),scope-mismatch-panic.test.ts(21),transpiler-stack-overflow.test.ts(1),test/bundler/bundler_regressions.test.ts(9). With release builds:transpiler.test.jshas 12 failing tests on the base and 282 pass with this PR.History and reference
export typebefore a newline, andimport type from#40818 put this tsc rule on main for the first token of the branch. Its description says that Bun follows tsc.bun check- a TypeScript type checker builtin to Bun #44361 had this carry injs_parserfor the type check parse only (sfx_right_of_assignmentandparse_arrow_body_with_flagsbehindis_tolerant()). Addbun lintandbun format#44805 removed that mode. The rule now lives inbun check's parser (src/sema/parser/parser/func.rs), sobun checkparses a file thatbun runrejects. This PR puts the carry into the transpiler. That needs a maintainer's yes.a ? (b) : c => dbetween ternary and arrow return type #34252 (open, changes requested) was the first attempt at the rule of parser: accept TypeScript arrow return types inside conditionals,export typebefore a newline, andimport type from#40818.parse_paren_expr. esbuild 0.25.1 rejects both lines of the Problem.Not in this PR
a ? () => (b) : c => dand matches more lines of the open TypeScript fix. It is not the rule of tsc 6.0.2.Self-review
A review of this change raised 33 concerns, and a second branch for the same two lines (see the comment below) gave three more. 31 are addressed:
<T>cast moved into parser: a:after(b as B)or<B[]>(b)is not an arrow function return type (TypeScript) #46209, with its tests. It acts outside conditionals.?and:, a first sight head goes through the attempt again, as on main. An earlier version skipped the attempt there. That was the rule of the closed parser: skip the trial parse fora ? (b: B): T => ... : c(panic withemitDecoratorMetadata) #46177, and it accepteda ? (): => b : c.a ? (b): T => q = (c) : d => e), TypeScript's ten cases and the seeded comparison withtypescript.:after(b as B)or<B[]>(b)is not an arrow function return type (TypeScript) #46209).async()in an arrow function body or an assignment is a call only if no reading leaves a:, soa ? q => async (): T => (d) : e => fis an error again and not a silent call.<T>(b: B)takes no attempt, so an error in its body is reported where it is.Five are not:
a ? (b) : c => d : eattempt inside another attempt keeps its arrow function #46311 first and add memory tests on top of it. parser: ana ? (b) : c => d : eattempt inside another attempt keeps its arrow function #46311 is a draft. The table above gives the cost without it.bun fmtround trip test next to the comparison withtypescript.Related
async(...)a call before a:in TypeScript #45743, parser: key the memo of thea ? (b) : c => dattempt by what the attempt reads #45939 and parser: a:after(b as B)or<B[]>(b)is not an arrow function return type (TypeScript) #46209 are below this PR. parser: a:after(b as B)or<B[]>(b)is not an arrow function return type (TypeScript) #46209 now has the cast operand rule.a ? (b) : c => d : eattempt inside another attempt keeps its arrow function #46311 (draft) changes the attempt in the same function. Its memo of failed attempts needs the mark andallow_awaitin the key. The PR that merges second takes that.parse_paren_exprfor parentheses around parameters.EXPECTED_VERSIONtakes the next free number at merge.