Custom parameters
Out of the box, Snout Router Path only supports very basic string-based
parameters that consume a single path segment. If you need more complicated
behavior, you can either bring in an additional dependency like
@snout/router-path-extras
, or you can implement your own custom parameters.
If you've implemented custom parameters for Snout Router Path, we'd love to hear about it! At the time of writing, it's early days for this project. We will most likely put together a list of custom parameter packages if/when they start appearing.
Matching and parsing
When matching a path against a pattern, the individual parameters contribute to
deciding whether the overall path matches the pattern or not. The exp
property of a route parameter is a regular expression that is used to match a
portion of the path. This expression must have exactly one
capturing group.
When a path matches a pattern, the content captured by exp
's
capturing group gets passed to parse
for further parsing. parse
itself
is not restricted to returning strings; it's possible to return any type of
value you want. JavaScript will obviously not complain about the return type.
TypeScript will expect you to honor the type variables used in the specified
Param
type.
These features work together to make it possible to implement just about any type of parameter you can imagine. As an example, consider parameters based on integers instead of strings:
- TypeScript
- JavaScript
import { Param, path } from "@snout/router-path";
function int<Name extends string>(name: Name): Param<Name, number> {
return {
name,
exp: /(0|[1-9]\d*)/, // `exp` allows integer strings only, no leading zeroes
format: (arg) => `${Math.floor(arg)}`,
parse: (match) => parseInt(match, 10),
};
}
const user = path`/user/${int("id")}`;
user.test("/user/111"); // returns true
user.test("/user/011"); // returns false
user.test("/user/abc"); // returns false
import { path } from "@snout/router-path";
function int(name) {
return {
name,
exp: /(0|[1-9]\d*)/, // `exp` allows integer strings only, no leading zeroes
format: (arg) => `${Math.floor(arg)}`,
parse: (match) => parseInt(match, 10),
};
}
const user = path`/user/${int("id")}`;
user.test("/user/111"); // returns true
user.test("/user/011"); // returns false
user.test("/user/abc"); // returns false
Before implementing your own custom parameters, be sure to check out the
@snout/router-path-extras
package, which has some commonly desired parameter
type implementations - including the integer parameters from the example above.
Parameter affixes
On a successful path match, only the capturing group content from exp
actually gets passed to parse
. This means it's possible to consume prefixes
and suffixes with exp
, without having to deal with them in parse
as
well:
- TypeScript
- JavaScript
import { Param, path } from "@snout/router-path";
function lang(): Param<"lang"> {
return {
name: "lang",
exp: /lang-([a-z]{2}-[A-Z]{2})/, // `exp` allows strings like "lang-en-US"
format: (arg) => `lang-${arg}`, // the prefix must be re-added when formatting
parse: (match) => match, // no need to strip the prefix when parsing
};
}
const documents = path`/documents/${lang()}`;
documents.match("/documents/lang-en-US"); // returns { lang: "en-US" }
documents.match("/documents/lang-ko-KR"); // returns { lang: "ko-KR" }
import { path } from "@snout/router-path";
function lang() {
return {
name: "lang",
exp: /lang-([a-z]{2}-[A-Z]{2})/, // `exp` allows strings like "lang-en-US"
format: (arg) => `lang-${arg}`, // the prefix must be re-added when formatting
parse: (match) => match, // no need to strip the prefix when parsing
};
}
const documents = path`/documents/${lang()}`;
documents.match("/documents/lang-en-US"); // returns { lang: "en-US" }
documents.match("/documents/lang-ko-KR"); // returns { lang: "ko-KR" }
Note that you can also build affixes into path pattern segments, which is another way to handle them. This is a simpler solution than implementing a custom parameter in many cases:
import { path } from "@snout/router-path";
const documents = path`/documents/lang-${"lang"}`;
documents.match("/documents/lang-en-US"); // returns { lang: "en-US" }
documents.match("/documents/lang-ko-KR"); // returns { lang: "ko-KR" }
Path separators
Although it's common to use /
as a separator for path segments, it's possible
to use exp
to implement paths that use other separators:
- TypeScript
- JavaScript
import { Param, path } from "@snout/router-path";
function dotted<Name extends string>(name: Name): Param<Name> {
return {
name,
exp: /([^.]+)/, // `exp` consumes everything up until the next `.`
format: (arg) => arg,
parse: (match) => match,
};
}
const roles = path`user.${dotted("username")}.roles`;
roles.match("user.piglet.roles"); // returns { username: "piglet" }
roles.match("user.snouty.roles"); // returns { username: "snouty" }
import { path } from "@snout/router-path";
function dotted(name) {
return {
name,
exp: /([^.]+)/, // `exp` consumes everything up until the next `.`
format: (arg) => arg,
parse: (match) => match,
};
}
const roles = path`user.${dotted("username")}.roles`;
roles.match("user.piglet.roles"); // returns { username: "piglet" }
roles.match("user.snouty.roles"); // returns { username: "snouty" }
Path segments
Parameters typically consume a single path segment, but this is actually
determined by exp
, which can be made to consume multiple path segments:
- TypeScript
- JavaScript
import { Param, path } from "@snout/router-path";
function anything<Name extends string>(name: Name): Param<Name> {
return {
name,
exp: /(.+)/,
format: (arg) => arg,
parse: (match) => match,
};
}
const article = path`/article/${anything("etc")}`;
article.match("/article/10 Biggest Snouts"); // returns { etc: "10 Biggest Snouts" }
article.match("/article/sci-fi/space-pigs"); // returns { etc: "sci-fi/space-pigs" }
import { path } from "@snout/router-path";
function anything(name) {
return {
name,
exp: /(.+)/,
format: (arg) => arg,
parse: (match) => match,
};
}
const article = path`/article/${anything("etc")}`;
article.match("/article/10 Biggest Snouts"); // returns { etc: "10 Biggest Snouts" }
article.match("/article/sci-fi/space-pigs"); // returns { etc: "sci-fi/space-pigs" }
Implementing parameters that consume multiple path segments can get complicated
very quickly. If you really need this kind of behavior, perhaps try using query
string parameters instead of route parameters, or check out the
@snout/router-path-extras
package, which has some commonly desired parameter
type implementations - including "repeating" parameter types such as any
and
some
.
Formatting
When building a path from a pattern, the individual parameters contribute to building the overall path by formatting the arguments they are passed. No matter what type of arguments a parameter accepts, it must always be able to format an argument into a string that makes up a portion of a path.
For example, the integer-based parameters used as an example in the
Matching and Parsing section are also responsible for formatting the numbers
they are passed when building a path that uses them. They do so by implementing
the format
method:
- TypeScript
- JavaScript
import { Param, path } from "@snout/router-path";
function int<Name extends string>(name: Name): Param<Name, number> {
return {
name,
exp: /(0|[1-9]\d*)/,
format: (arg) => `${Math.floor(arg)}`, // `format` formats a number as an integer string
parse: (match) => parseInt(match, 10),
};
}
const user = path`/user/${int("id")}`;
user.build({ id: 111 }); // returns "/user/111"
user.build({ id: 222 }); // returns "/user/222"
import { path } from "@snout/router-path";
function int(name) {
return {
name,
exp: /(0|[1-9]\d*)/,
format: (arg) => `${Math.floor(arg)}`, // `format` formats a number as an integer string
parse: (match) => parseInt(match, 10),
};
}
const user = path`/user/${int("id")}`;
user.build({ id: 111 }); // returns "/user/111"
user.build({ id: 222 }); // returns "/user/222"
When implementing custom parameters, it's important that the two "halves" of the implementation match up. In other words, a parameter should always be able to parse its own formatting.
Type safety
If you're using TypeScript, you can take advantage of type checking in your
custom parameters by implementing the Param
interface correctly. For
instance, using the integer parameters from previous examples, TypeScript can
warn us when we try to use a parameter that does not exist, or if we try to use
an incorrect variable type with a parameter that does exist:
const user = path`/user/${int("id")}`;
// TypeScript "knows" that a successful match will contain a number for "id":
const match = user.match("/user/111");
if (match) {
console.log(match.id.toExponential()); // no error
console.log(match.id.toUpperCase()); // type error
console.log(match.nonexistent); // type error
}
// TypeScript "knows" that we need a number for "id" when building the path:
user.build({ id: 111 }); // no error
user.build({ id: "111" }); // type error
user.build({}); // type error
Type-safe parameter names
You may have noticed by reading the examples in this guide, that Param
has a
Name
type variable for the parameter name. This literal type
contains the name of the parameter, but in a way that TypeScript's type system
understands. This is a key part of how TypeScript can know about the
existence of route parameters.
The best way to ensure that custom parameters have the correct
Name
type associated with them, is to use a generic function to
create them:
import { Param, path } from "@snout/router-path";
function customParam<Name extends string>(name: Name): Param<Name> {
return {
name,
exp: /([^/]+)/,
format: (arg) => arg,
parse: (match) => match,
};
}
const category = customParam("category");
const id = customParam("id");
const article = path`/article/${category}/${id}`;
It's also possible to directly create one-off parameters with literal type names in various ways using TypeScript syntax:
import { Param, path } from "@snout/router-path";
// explicit
const category: Param<"category"> = {
name: "category",
exp: /([^/]+)/,
format: (arg) => arg,
parse: (match) => match,
};
// implicit
const id = {
name: "id" as "id",
exp: /([^/]+)/,
format: (arg: string) => arg,
parse: (match: string) => match,
};
const article = path`/article/${category}/${id}`;