@simspace/trout v0.0.6
🐟 trout
(typesafe routing)
Install
NPM
npm install @simspace/trout
Yarn
yarn add @simspace/trout
Making a route
To make a route, you must either extend an existing path, or extend the RootPath. You do this with the path function, passing the segments that you wish to extend.
import * as tr from 'trout'
const usersRoute = pipe(
tr.RootPath,
tr.path('users')
)Here, usersRoute represents the path: /users.
Route parameters
We can add route parameters by using the param function:
import * as tr from 'trout'
const userProfileRoute = pipe(
usersRoute,
tr.param('userId', tr.stringRC)
)Here, we've added a route parameter named userId, and passed the stringRC route codec to indicate that this parameter's type is a string. When encoding and decoding this route, trout will use encodeURIComponent and decodeURIComponent respectively to ensure any string passed into the route constructor is serializable as a valid URL string. More complex codecs (for non-string types) can be built upon this, as we'll see below.
Query parameters
We can add query parameters to a route with the queryParam/queryParamOp functions:
const usersRoute = pipe(
tr.RootPath,
tr.path('users'),
tr.queryParam('page', tr.numberRC),
tr.queryParamOp('pageSize', tr.numberRC)
)The queryParam function takes the name of the query parameter, and the codec, which determines the type of the parameter. Here, we expect to get a parameter named page which will be attempted to be decoded as a number. queryParamOp works similarly to queryParam except that it specifies an optional query parameter (encoded using Option from fp-ts).
Building URLs
Now that we have a route, we can construct URLs from it:
tr.encodeUrl(usersRoute)({
query: {
page: 5,
pageSize: O.none
}
}) // gives us: "/users?page=5"The encodeUrl function ensures we have the correct types for all of our parameters.
Extending existing routes
A route can be extended further with more segments via the path (or param) function. Adding another path segment will invalidate all query parameters from the previous route. Here, usersRoute has a page parameter, but since userProfileRoute added another route segment (via param), those query parameters are not included in userProfileRoute.
const userProfileRoute = pipe(
usersRoute,
tr.param('userId', tr.stringRC)
)Route Codecs
To encode and decode parameters in a typesafe manner, trout uses route codecs. trout provides a number of basic codecs out of the box:
stringRCfor valid URLstringsnumberRCfornumbers (excludingNaNandbigints)booleanRCforbooleansstringLiteralRCandnumberLiteralRCfor literalstringandnumbervalues (or unions of those values)dateRCforDates (encoded in the URL as a string in simplified extended ISO format, the result of callingDate.prototype.toISOString())
There are also a few useful codec combinators for building more complex codecs:
newtypeRCtakes anIsofor theNewtype(à lanewtype-ts), and an underlying route codec, will map the codec's type into aNewtypearrayRCtakes a route codec, and returns a route codec for a homogenous array of those values (only for query parameters, represented in the URL as comma-separated values)tupleRCtakes a pair of codecs, and will encode and decode a two-element array as a tupleliteralRCtakes a set of literal values and anEqinstance for those values, will encode and decode as a union of those values (stringLiteralRCandnumberLiteralRCare implemented withliteralRC)
trout's RouteCodec is implemented in terms of io-ts/Codec, so combinators from that library may also be used in constructing custom codecs.
The newtypeRC route codec can be used to decode parameters with a supplied iso function. Here's an example where the userId parameter is specified as a UserIdM (instead of just a string):
interface UserIdM extends Newtype<{readonly UserIdM: unique symbol}, string> {}
const isoUserId = iso<UserIdM>()
const userProfilePath = pipe(
usersRoute,
tr.param(
'userId',
tr.newtypeRC(isoUserId, tr.stringRC)
)
)Now, when we try to construct this route, we need to supply a UserIdM:
tr.encodeUrl(userProfilePath)({
route: {
page: 5,
userId: isoUserId.wrap('bob')
}
})Decoding values from a URL
The decodeUrl function takes a route and an URL string, giving us back an Either, having a Left of errors (if the URL doesn't match this route), or a Right of the decoded values:
const result1 = tr.decodeUrl(
userProfilePath
)('/users/5/bob')
// right({
// routeParams: {
// page: 5,
// userId: 'bob'
// },
// queryParams: {}
// })
const result2 = tr.decodeUrl(
userProfilePath
)('/users/bob')
// left('Path segment count mismatch')
const result3 = tr.decodeUrl(
userProfilePath
)('/user/5/bob')
// left('Path segment users does not match user')Usage with React Router
The getRouteString function will provide a react-router-compatible string which can be used in the path prop of a react router Route component.
<Route>
<Route route={tr.getRouteString(usersRoute)} component={UsersList} />
<Route route={tr.getRouteString(userProfilePath)} component={UsersList} />
</Route>Inside the component, you can decode the URL by combining the location with the search values:
const UsersList = ({location}: UsersListProps) => {
const decoded = tr.decodeUrl(usersRoute)(location.pathname + location.search)
...
}