remix-run/remix · error · ParseError

empty optional

Error message

empty optional

What it means

An optional group in the pattern contains no consuming tokens at all — e.g. '()' — making it meaningless and a likely authoring mistake. After parsing, if any optional group was recorded as empty, parsePart throws 'empty optional' at the group's source position.

Solutions

  1. Remove the empty '()' group
  2. If the group was meant to hold an optional param, put the param inside: '(/:page)'
  3. Fix the generator so it omits the whole group when its content is empty

Example fix

// before
let pattern = parseRoutePattern(`/users/:id${opt}`) // opt === ''

// after
let pattern = parseRoutePattern(opt ? `/users/:id(${opt})` : '/users/:id')
Defensive patterns

Strategy: validation

Validate before calling

if (/\(\)/.test(source) || /\(\(\)\)/.test(source)) throw new Error('Empty optional group')

Type guard

null

Try / catch

try { parseRoutePattern(source) } catch (e) { if (e instanceof ParseError && e.message === 'empty optional') { /* remove the () at e.position */ } }

Prevention

When it happens

Trigger: Calling parseRoutePattern with a source containing '()' or '(())' anywhere in a part, including groups whose only content is another empty optional.

Common situations: Template code that wraps an optional segment when the segment string is empty; cleanup scripts stripping params but leaving the parens; copy-paste leftovers.

Related errors


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

Appendix: source

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

    if (char === '\\') {
      if (i + 1 === span[1]) {
        throw new ParseError('dangling escape', source, i)
      }
      let text = source.slice(i + 1, i + 2)
      appendText(text)
      i += 2
      continue
    }

    appendText(char)
    i += 1
  }
  let unmatchedOptional = optionalStack.at(-1)
  if (unmatchedOptional !== undefined) {
    throw new ParseError('unmatched (', source, unmatchedOptional.sourceIndex)
  }
  if (emptyOptionals.length > 0) {
    throw new ParseError('empty optional', source, emptyOptionals[0])
  }

  validateTokenGrammar(source, tokens, optionals, tokenIndices)
  validateOptionalCaptureSchemas(source, tokens, optionals, tokenIndices)

  return { tokens, optionals, type: options.type }
}

function validateTokenGrammar(
  source: string,
  tokens: ReadonlyArray<PartPatternToken>,
  optionals: ReadonlyMap<number, number>,
  tokenIndices: ReadonlyArray<number>,
): void {
  for (let [index, token] of tokens.entries()) {
    if (token.type !== ':' && token.type !== '*') continue

    for (let next of nextConsumingTokenIndices(tokens, optionals, index + 1)) {

View on GitHub (pinned to 9696913134)