Skip to content

Proposal: Allow Static and Param Wildcard Siblings at the Same Path Level

xushiwei edited this page Mar 15, 2026 · 2 revisions

Summary

This proposal extends the radix tree router to support registering both static child routes and a :param wildcard route under the same parent node, by enforcing a priority ordering at insertion time: static children are always placed before the param child in n.children. The existing match-first-wins traversal logic in Route and FindCaseInsensitivePath then requires no changes — static routes naturally win because they appear first.


Motivation

Currently, registering the following three routes (in any order) panics:

/dev/document-api/apiReference/model/videoModels
/dev/document-api/apiReference/model/imageModels
/dev/document-api/apiReference/model/:model
panic: wildcard segment ':model' conflicts with existing children
       in path '/dev/document-api/apiReference/model/:model'

The restriction is overly conservative. Static segments are unambiguous — a request for /model/videoModels can only match the literal videoModels child or fall through to :model; there is no ambiguity. The natural precedence rule static beats wildcard resolves every lookup deterministically, without any change to traversal logic.

Go's http.ServeMux, chi, gin, and echo all permit this pattern. Users migrating from those routers reasonably expect the same behaviour here.


Design Principle: Ordering at Insertion, Not at Lookup

The existing Route walk already iterates n.children in order and returns on the first match. The only thing needed to implement static-over-param priority is to guarantee that static children are inserted before the param child.

This means:

  • Route, Route[T], and findCaseInsensitivePathRec are unchanged.
  • incrementChildPrio reordering is unchanged (it only reorders within the static-index slice, see §Changes).
  • The diff is confined to insertChild and one guard in AddRoute.

Proposed Behaviour

Request path Matched route
/model/videoModels /model/videoModels (static, exact)
/model/imageModels /model/imageModels (static, exact)
/model/textModels /model/:model, model=textModels (param)
/model/anything /model/:model, model=anything (param)

Priority rule (no ambiguity, resolved purely by children ordering):

static  >  param  >  catchAll

catchAll siblings remain forbidden: a catch-all consumes the entire remainder of the path, making any sibling permanently unreachable regardless of ordering.


Children Layout

parent node  (path = "/model/")
├── children[0]  static  "videoModels"    ← static children first, ordered by priority
├── children[1]  static  "imageModels"
└── children[2]  param   ":model"         ← param child always last

n.indices continues to index only static children (as it does today). The param child sits at children[len(static_children)] and is not represented in n.indices. The traversal loop for i, c := range []byte(n.indices) therefore never accidentally matches the param child — it is tried separately only after all static children miss, exactly as the current n.wildChild branch does today.


Required Changes

1. Node struct — no changes required

wildChild bool retains its current meaning: "the last child is a wildcard (param or catchAll)". The only difference is that there may now be static children in front of it.

2. insertChild — relax the children-conflict guard for :param

// Before
if len(n.children) > 0 {
    panic("wildcard segment '" + wildcard +
        "' conflicts with existing children in path '" + fullPath + "'")
}
 
// After
if len(n.children) > 0 {
    // A :param wildcard may coexist with existing static children.
    // catchAll is still forbidden: it would shadow every sibling.
    if wildcard[0] != ':' {
        panic("wildcard segment '" + wildcard +
            "' conflicts with existing children in path '" + fullPath + "'")
    }
    // A second param wildcard at the same level is also forbidden.
    for _, child := range n.children {
        if child.nType == param {
            panic("a param wildcard is already registered for path '" + fullPath + "'")
        }
    }
    // Existing static children stay in place; the param child is appended last.
    // n.wildChild = true is set below, as before.
}

The param child is appended to n.children after all existing static children, so children[len(static)-1] is the param node. n.wildChild is set to true as it is today.

3. AddRoute — allow new static children when a param sibling already exists

In the walk loop, when n.wildChild is true the current code immediately descends into n.children[0]. With mixed children this must only happen when the incoming segment is itself a wildcard. For static incoming segments, fall through to the normal index-scan / new-child insertion path:

// Before
if n.wildChild {
    n = n.children[0]
    n.priority++
    // … wildcard conflict checks …
    continue walk
}
 
// After
if n.wildChild {
    idxc := path[0]
    if idxc == ':' || idxc == '*' {
        // Incoming segment is a wildcard — descend into the existing wildcard child.
        n = n.children[len(n.children)-1] // param child is always last
        n.priority++
        // … existing wildcard conflict checks unchanged …
        continue walk
    }
    // Incoming segment is static — fall through to the index scan below.
    // The static child will be inserted before the param child (see step 4).
}

4. AddRoute — insert new static children before the param child

When a new static child is appended to n.children and n.wildChild is true, the param child must be moved to remain last:

// Before (simplified)
n.indices += string([]byte{idxc})
child := &Node[H]{}
n.children = append(n.children, child)
n.incrementChildPrio(len(n.indices) - 1)
n = child
 
// After
n.indices += string([]byte{idxc})
child := &Node[H]{}
if n.wildChild {
    // Insert the new static child before the param child to maintain ordering.
    // n.children layout: [...static..., param]
    // After insert:       [...static..., newStatic, param]
    // then swap newStatic and param so param stays last.
    n.children = append(n.children, child)           // append placeholder
    last := len(n.children) - 1
    n.children[last], n.children[last-1] =           // swap: static before param
        n.children[last-1], n.children[last]
    n.incrementChildPrio(last - 1)
    n = child
} else {
    n.children = append(n.children, child)
    n.incrementChildPrio(len(n.indices) - 1)
    n = child
}

incrementChildPrio reorders static children among themselves by priority but will never bubble a static child past the param child, because the param child has no entry in n.indices and the reorder loop is bounded by n.indices length.

5. Route / Route[T] / findCaseInsensitivePathRecno changes

The existing code already:

  1. Scans n.indices for a static match and descends if found.
  2. Falls through to the n.wildChild branch (which reads n.children[0]) if no static match.

After this proposal n.children[0] is no longer guaranteed to be the param child — static children come first. The n.wildChild branch must instead read the last child:

// Before (in Route walk and findCaseInsensitivePathRec)
n = n.children[0]
 
// After
n = n.children[len(n.children)-1]

This is the only change required in the lookup path, and it is a one-liner. All switch/case logic for param and catchAll node types is unchanged.


Summary of Diff

Location Change
insertChild Relax children-conflict guard; allow :param alongside static children; append param child last
AddRoute (walk) When n.wildChild && path[0] is static, skip the wildcard-descent branch
AddRoute (child insertion) When n.wildChild, insert new static child before param child and swap
Route / Route[T] n.children[0]n.children[len(n.children)-1] in the wildChild branch
findCaseInsensitivePathRec Same one-liner change as above

All other logic — priority reordering, TSR detection, catchAll handling, case-insensitive recursion — is untouched.


Non-Goals

  • CatchAll (*name) siblings remain forbidden.
  • Multiple param wildcards at the same level remain forbidden.
  • Param vs param name mismatch (e.g. :id and :name as siblings) remains forbidden.

Migration / Breaking Changes

Fully backward compatible. All previously valid route sets are unaffected. The previously forbidden pattern of mixing static children with a :param sibling becomes valid and deterministic.


Example

var root Node[http.Handler]
 
root.AddRoute("/model/videoModels", videoHandler)
root.AddRoute("/model/imageModels", imageHandler)
root.AddRoute("/model/:model",      genericHandler) // no longer panics
 
h, _, _ := root.Route("/model/videoModels") // → videoHandler
h, _, _ = root.Route("/model/imageModels")  // → imageHandler
h, _, _ = root.Route("/model/textModels")   // → genericHandler, model="textModels"

References

  • httprouter issue #329 — upstream discussion on the same limitation
  • Go net/http ServeMux routing precedence — longer/more-specific patterns win
  • chi router — static segments evaluated before wildcards by construction