{"record":{"id":"7b3270e91aa26cee","repo":"remix-run/remix","slug":"ambiguous-optional-captures","errorCode":null,"errorMessage":"ambiguous optional captures","messagePattern":"ambiguous optional captures","errorType":"validation","errorClass":"ParseError","httpStatus":null,"severity":"error","filePath":"packages/route-pattern/src/lib/route-pattern/parse.ts","lineNumber":226,"sourceCode":"  return result\n}\n\nfunction validateOptionalCaptureSchemas(\n  source: string,\n  tokens: ReadonlyArray<PartPatternToken>,\n  optionals: ReadonlyMap<number, number>,\n  tokenIndices: ReadonlyArray<number>,\n): void {\n  for (let [begin, end] of optionals) {\n    let nextBegin = end + 1\n    if (tokens[nextBegin]?.type !== '(') continue\n    let nextEnd = optionals.get(nextBegin)\n    if (nextEnd === undefined) continue\n\n    let first = optionalStructure(tokens, begin + 1, end)\n    let second = optionalStructure(tokens, nextBegin + 1, nextEnd)\n    if (first.structure === second.structure && first.captures !== second.captures) {\n      throw new ParseError('ambiguous optional captures', source, tokenIndices[nextBegin])\n    }\n  }\n}\n\nfunction optionalStructure(\n  tokens: ReadonlyArray<PartPatternToken>,\n  begin: number,\n  end: number,\n): { structure: string; captures: string } {\n  let structure = ''\n  let captures = ''\n  for (let i = begin; i < end; i++) {\n    let token = tokens[i]\n    if (token.type === ':' || token.type === '*') {\n      structure += token.type\n      captures += `${token.type}:${token.name}|`\n    } else if (token.type === 'text') {\n      structure += `t:${token.text}|`","sourceCodeStart":208,"sourceCodeEnd":244,"githubUrl":"https://github.com/remix-run/remix/blob/9696913134be3a4423513d2775f7b31d6917c049/packages/route-pattern/src/lib/route-pattern/parse.ts#L208-L244","documentation":"When two optional groups at the same position have identical token structure but different capture/param names (e.g. '(/:id)(/:postId)'), the matcher cannot disambiguate which captures to bind, and matching/space serialization would be ambiguous. validateOptionalCaptureSchemas throws 'ambiguous optional captures' at the second group's position.","triggerScenarios":"Calling parseRoutePattern with adjacent sibling optional groups whose inner structure matches but param names differ, like '/users(/:a)(/:b)' or '/x(.:ext)(.:type)'.","commonSituations":"Attempting multi-format suffixes; refactoring '/:a/:b' into optionals while renaming params; generators emitting sibling optionals per variant.","solutions":["Give the groups structurally different content (different literals/prefixes): '(.json)(/:id)'","Use the same param name if semantics allow: '(/:id)(/:id)' is still ambiguous — instead merge into one optional '(/:id)'","Model variants as separate patterns instead of stacked optionals"],"exampleFix":"// before\nlet pattern = parseRoutePattern('/users(/:id)(/:postId)')\n\n// after\nlet pattern = parseRoutePattern('/users(/:id)')\n// or disambiguate: '/users(id/:id)(postId/:postId)'","handlingStrategy":"validation","validationCode":"// reject sibling optionals with same shape but different names, e.g. (/ :a)(/ :b)\nif (/\\([^)]*\\)\\([^)]*\\)/.test(source)) { /* audit sibling optional groups */ }","typeGuard":"null","tryCatchPattern":"try { parseRoutePattern(source) } catch (e) { if (e instanceof ParseError && e.message === 'ambiguous optional captures') { /* differentiate structure or merge groups */ } }","preventionTips":["Avoid stacked sibling optionals; prefer separate patterns per variant","Give sibling optionals distinct literal prefixes"],"tags":["route-pattern","parse","optional-segments","ambiguity"],"backgroundTag":"invalid-route-pattern-syntax","analyzedSha":"9696913134be3a4423513d2775f7b31d6917c049","analyzedAt":"2026-08-27T19:55:01.024Z","schemaVersion":2},"datasetVersion":"2026-08-28T00:17:15.603Z"}