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
- 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
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
- Avoid stacked sibling optionals; prefer separate patterns per variant
- Give sibling optionals distinct literal prefixes
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.