Skip to main content

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.

tip

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:

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
tip

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:

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" }

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:

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" }

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:

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" }
danger

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:

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"
tip

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}`;