remix-run/remix · error · ParseError

ambiguous optional captures

Error message

ambiguous optional captures

What it means

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.

Source

Thrown at packages/route-pattern/src/lib/route-pattern/parse.ts:226

  return result
}

function validateOptionalCaptureSchemas(
  source: string,
  tokens: ReadonlyArray<PartPatternToken>,
  optionals: ReadonlyMap<number, number>,
  tokenIndices: ReadonlyArray<number>,
): void {
  for (let [begin, end] of optionals) {
    let nextBegin = end + 1
    if (tokens[nextBegin]?.type !== '(') continue
    let nextEnd = optionals.get(nextBegin)
    if (nextEnd === undefined) continue

    let first = optionalStructure(tokens, begin + 1, end)
    let second = optionalStructure(tokens, nextBegin + 1, nextEnd)
    if (first.structure === second.structure && first.captures !== second.captures) {
      throw new ParseError('ambiguous optional captures', source, tokenIndices[nextBegin])
    }
  }
}

function optionalStructure(
  tokens: ReadonlyArray<PartPatternToken>,
  begin: number,
  end: number,
): { structure: string; captures: string } {
  let structure = ''
  let captures = ''
  for (let i = begin; i < end; i++) {
    let token = tokens[i]
    if (token.type === ':' || token.type === '*') {
      structure += token.type
      captures += `${token.type}:${token.name}|`
    } else if (token.type === 'text') {
      structure += `t:${token.text}|`

View on GitHub (pinned to 9696913134)

Solutions

  1. Give the groups structurally different content (different literals/prefixes): '(.json)(/:id)'
  2. Use the same param name if semantics allow: '(/:id)(/:id)' is still ambiguous — instead merge into one optional '(/:id)'
  3. Model variants as separate patterns instead of stacked optionals

Example fix

// before
let pattern = parseRoutePattern('/users(/:id)(/:postId)')

// after
let pattern = parseRoutePattern('/users(/:id)')
// or disambiguate: '/users(id/:id)(postId/:postId)'
Defensive patterns

Strategy: validation

Validate before calling

// reject sibling optionals with same shape but different names, e.g. (/ :a)(/ :b)
if (/\([^)]*\)\([^)]*\)/.test(source)) { /* audit sibling optional groups */ }

Type guard

null

Try / catch

try { parseRoutePattern(source) } catch (e) { if (e instanceof ParseError && e.message === 'ambiguous optional captures') { /* differentiate structure or merge groups */ } }

Prevention

When it happens

Trigger: Calling parseRoutePattern with adjacent sibling optional groups whose inner structure matches but param names differ, like '/users(/:a)(/:b)' or '/x(.:ext)(.:type)'.

Common situations: Attempting multi-format suffixes; refactoring '/:a/:b' into optionals while renaming params; generators emitting sibling optionals per variant.

Related errors


AI-assisted analysis of remix-run/remix@9696913134 (2026-08-27). Data as JSON: /api/errors/7b3270e91aa26cee. Report an issue: GitHub.