Express 5 24,430 matches paths with path-to-regexp 8, a deliberate break with the version Express 4 used. The old syntax let regular-expression operators leak into path strings, where /ab?cd meant "the b is optional" and /* meant "anything". Those readings were a frequent source of accidental catch-alls and of regular-expression denial-of-service reports. Version 8 reserves ? + * ( ) [ ] ! and throws a PathError the moment you register a pattern that uses them the old way — at startup, not at request time.
import express from 'express';
for (const path of ['/files/*', '/books/:id?', '/:id(\\d+)', '/(user|users)/:id']) {
try {
express().get(path, (req, res) => res.end('ok'));
console.log('OK ', path);
} catch (err) {
console.log('THROWS', path, '->', err.constructor.name + ': ' + err.message.split(';')[0]);
}
}THROWS /files/* -> PathError: Missing parameter name at index 8: /files/* THROWS /books/:id? -> PathError: Unexpected ? at index 10: /books/:id? THROWS /:id(\d+) -> PathError: Unexpected ( at index 4: /:id(\d+) THROWS /(user|users)/:id -> PathError: Unexpected ( at index 1: /(user|users)/:id
Three replacements cover almost every Express 4 pattern. A wildcard must be named, so * becomes *splat (any name works) and captures an array of segments, not a string — /files/img/icons/logo.png matched by /files/*filepath yields ['img', 'icons', 'logo.png'], and req.params.filepath.join('/') rebuilds it. Optional parts go in braces: {/:id} makes the whole segment optional, {.:ext} makes a suffix optional. Alternation is gone, but app.get() still accepts an array of paths, which reads better anyway.
| Express 4 | Express 5 | Matches |
|---|---|---|
| /files/* | /files/*splat | /files/a/b, not /files |
| /* | /{*splat} | every path, root included |
| /books/:id? | /books{/:id} | /books and /books/7 |
| /:file.:ext? | /:file{.:ext} | /logo.png and /logo |
| /:id(\d+) | /:id plus a check | any segment; validate in code |
| /(user|users)/:id | ['/user/:id', '/users/:id'] | both prefixes |
Two traps hide in that list. /files/* throws, but /ab*cd does not — *cd parses as a wildcard named cd, so the route silently matches far more than you meant. And /*splat excludes the root path, so a catch-all that must also answer / needs the braced form /{*splat}. When a pattern genuinely needs character-class precision, pass a real RegExp instead of a string; its capture groups arrive as req.params[0], req.params[1] and so on.