DEV Community

loading...
Cover image for Resource acquisition with Typescript, Reader Monad and FP-TS

Resource acquisition with Typescript, Reader Monad and FP-TS

nidble
・Updated on ・3 min read

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')
Enter fullscreen mode Exit fullscreen mode

But even if this approach is very simple there are some downsides...

  1. it is not fully testable
  2. compile type safety is absent
  3. it changes variable state
  4. 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'
Enter fullscreen mode Exit fullscreen mode

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')),

Enter fullscreen mode Exit fullscreen mode

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)
)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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!

Discussion (0)