How many times you have to converting/mapping something from-to different domains?
This post shows an example of how is possible to make a conversion from a string to a custom mapped type with all benefit of compile-time type safeness, functional programming and easy to test.
Introduction
Suppose we are in a Node environment and we want to map a string URL into a custom interface.
Node.js is shipped with an URL
class capable of parsing an URL string and extract all bit of pieces: https://nodejs.org/api/url.html
Accessing the query parameters of the URL is very straightforward:
interface UriParams {
partyId: string
passphrase: string
username: string
ticket: string
}
function convertToUriParams(u: string) {
const { searchParams: sp } = new URL(u, 'http://0.0.0.0/')
const fallbacks = {
partyId: uuid(),
passphrase: '',
username: 'Anoymous',
ticket: '',
}
const entries = Object.entries(fallbacks)
const params = entries.map(([k, f]: [string, string | boolean]) => [k, sp.get(k) ?? f])
return Object.fromEntries(params)
}
convertToUriParams('party?partyId=42&passphrase=fidelio')
But even if this approach is very simple there are some downsides...
- it is not fully testable
- compile type safety is absent
- it changes variable state
- every change breaks SOLID principles.
How can rewrite it taking advantage of FP and Typescript type checking?
Reader Monad
Reader Monad is FP Monad capable to access (read) a resource and extracting all needed parts by taking full advantage of DI in a pure functional fashion, for more detail please read Reader Monad.
But how can map this monad with our UriParams
?
Borrowing Reader from fp-ts lib we can start refactoring and encapsulating URL's searchParams
as a dependency. Then we can access underlying data with something like that:
import * as R from 'fp-ts/Reader'
const { searchParams } = new URL('ws?userId=42', 'http://0.0.0.0/')
const getParamOr = (k: string, def = ''): R.Reader<URLSearchParams, string> =>
R.asks((url: URLSearchParams) => url.get(k) ?? def)
// ...
const username = getParamOr('passphrase', 'fidelio')(searchParams) // => 'fidelio'
But how can apply this pattern to the full UriParams
interface?
Looking from docs a possible solution could be to lift our Reader with sequenceS
and rewriting the previous example in order to accept non-empty structs too:
import * as R from 'fp-ts/Reader'
import { sequenceS } from 'fp-ts/Apply'
const factoryReader = (uuid: string) => ({
partyId: getParamOr('partyId', uuid),
passphrase: getParamOr('passphrase', ''),
username: getParamOr('username', 'Anoymous'),
ticket: getParamOr('ticket', ''),
})
// ...
sequenceS(R.reader)(factoryReader('zzz-yyy-xxx')),
Our code now is already more robust and flexible than before.
Adding specialization
Let try to add a new feature: how can we deal with fields that don't belong 1:1 to the URL string but are indirectly connected, for instance, with another field like partyId
as a boolean?
Fair enough, with a Monad we can write a new accessor isEmpty
that:
// ...
import { pipe } from 'fp-ts/pipeable'
const isEmpty = (k: string): R.Reader<URLSearchParams, boolean> => pipe(
getParamOr(k, ''),
R.map((s) => '' === s)
)
And then add this to our factoryReader
:
import * as R from 'fp-ts/Reader'
import { pipe } from 'fp-ts/pipeable'
import { sequenceS } from 'fp-ts/Apply'
interface UriParams {
partyId: string
passphrase: string
username: string
ticket: string
isNew: boolean
}
const factoryReader = (uuid: string) => ({
partyId: getParamOr('partyId', uuid),
passphrase: getParamOr('passphrase', ''),
username: getParamOr('username', 'Anoymous'),
ticket: getParamOr('ticket', ''),
isNew: isEmpty('partyId'), //
})
// ...
const parser = sequenceS(R.reader)(factoryReader(uuid)),
parser(searchParams)
Invocation improvements
We have gained a lot of code improvement for now, but the actual implementation of factoryReader
accepts only URL
instances.
How can we rethink our factoryReader
to accept a string and to inject automatically a searchParam
instead?
Yes, we can... We can solve this problem using local
method (a sort of contramap
) and wrapping sequenceS
with a pipe
inside a new function:
export const extractUriParams = (uuid: string): R.Reader<string, UriParams> => pipe(
sequenceS(R.reader)(factoryReader(uuid)),
R.local((u: URL) => u.searchParams),
R.local((url: string) => new URL(url, 'http://0.0.0.0/'))
)
// ...
extractUriParams('xxx-yyy-zzz')('party/?partyId=321&passphrase=fidelio&b=2&c=3')
Conclusion
For the full code please refer to this link Codesandbox
I hope that you enjoy this post and if so please, share, like-it, and/or drop a comment. Thank you!
Top comments (0)