1

Given the code below, are there cleaner ways to express many nested map functions in TypeScript? I love the Scala "for-comprehension" for this use-case, but I can't find the equivalent in TypeScript. I feel like I'm missing something pretty obvious here.

I have several objects that can fail instantiation for validation reasons, so the return types are all Either<string, T>. For instance:

const userId: Either<string, UserId> = UserId.create('1234')

When composing objects that are made up of many statements like the one above, it gets gnarly to look at. All variables in the example have been replaced with strings for readability.

In TypeScript, this is what I'm doing. Is there a cleaner way to express this without losing my types?

const userSettings: Either<string, UserSettings> = UserId.create('1234').chain(userId => {
  return Email.create('hello@world.com').chain(email => {
    return Active.create(183).chain(active => {
      return Role.create('admin').map(role => {
        return UserSettings(userId, email, active, role)
      })
    })
  })
})

In Scala, I would express the above code like this:

for {
  userId <- UserId.create('1234')
  email  <- Email.create('hello@world.com')
  active <- Active.create(183)
  role   <- Role.create('admin')
} yield UserSettings(userId, email, active, role)

I'm using the Purify library for types such as Either.

Does anyone have any tips, suggestions, and/or libraries that could help clean up my nested map functions TypeScript mess?

dbrown428
  • 183
  • 1
  • 12
  • 1
    It might help clean it up a bit if you use the implicit return of arrow functions. This will get rid of the curly braces and the `return`. – iz_ May 13 '21 at 19:01
  • please share reproducable example – captain-yossarian from Ukraine May 13 '21 at 19:17
  • 1
    From Purity's docs, the `chain` method you allude to is the same thing as `flatMap` in Scala (i.e. it expresses the monadic bind operation). Scala includes syntactic sugar to translate `for` expressions into `flatMap` (and `map` and sometimes even `withFilter`) calls; I'm not exceptionally familiar with TypeScript, but I don't think it has a way to usefully extend the syntax. (NB: I actually prefer writing out `flatMap` and friends to using `for` notation) – Levi Ramsey May 13 '21 at 19:19
  • 1
    That said, https://apoberejnyi.medium.com/do-notation-for-either-in-typescript-c207e5987b7a presents a technique that might work (abusing exceptions for control flow), though it doesn't get one all the way to the `for`/`do` notation. Whether this is an improvement over the explicit `chain`s is in the eye of the beholder, I guess. – Levi Ramsey May 13 '21 at 19:24
  • "*Technically the above 'map' are 'chain' when using the Purity library, but I don't think that changes the nature of the question.*" - actually, it does. Why did you change them? A nested `map` cannot be unnested, whereas a nested `flatMap` can. – Bergi May 13 '21 at 19:26
  • @Bergi, I will correct the question. Thanks for the clarification. – dbrown428 May 13 '21 at 19:43
  • I'm not familiar with Purify, but you could avoid nesting using [fp-ts' implementation of do notation](https://gcanti.github.io/fp-ts/guides/do-notation.html). – Lauren Yim Aug 21 '21 at 08:15

1 Answers1

0

You could use something like this:

const userSettings = Right({})
  .chain(acc => UserId.create('1234').map(userId => ({...acc, userId})))
  .chain(acc => Email.create('hello@world.com').map(email => ({...acc, email})))
  .chain(acc => Active.create(183).map(active => ({...acc, active})))
  .chain(acc => Role.create('admin').map(role => ({...acc, role})))
  .map(({userId, email, active, role}) => UserSettings(userId, email, active, role))

You could also define a helper function:

// This implementation works for all functors, but the types only work for
// Either due to TypeScript's lack of HKTs
const bind =
  <N extends string, A extends object, L, R>(
    name: Exclude<N, keyof A>,
    f: (acc: A) => Either<L, R>
  ) =>
  (acc: A): Either<L, A & Record<N, R>> =>
    f(acc).map(r => ({...acc, [name]: r} as A & Record<N, R>))

const userSettings: Either<string, UserSettings> = Right({})
  .chain(bind('userId', () => UserId.create('1234')))
  .chain(bind('email', () => Email.create('hello@world.com')))
  .chain(bind('active', () => Active.create(183)))
  .chain(bind('role', () => Role.create('admin')))
  .map(({userId, email, active, role}) => UserSettings(userId, email, active, role))

Having bind take a function allows for things like this:

Right({})
  .chain(bind('a', () => Right(1)))
  // The value of b depends on a
  .chain(bind('b', ({a}) => Right(a + 1)))
  // a is 1 and b is 2
  .map(({a, b}) => `a is ${a} and b is ${b}`)

This is pretty much a port of fp-ts' implementation of do notation, so all credit goes to Giulio Canti and the contributors of fp-ts.

If you're writing () => a lot, you could use another helper:

// This could definitely be named better
const bind_ = <N extends string, A extends object, L, R>(
  name: Exclude<N, keyof A>,
  either: Either<L, R>
): ((acc: A) => Either<L, A & Record<N, R>>) => bind(name, () => either)

const userSettings: Either<string, UserSettings> = Right({})
  .chain(bind_('userId', UserId.create('1234')))
  .chain(bind_('email', Email.create('hello@world.com')))
  .chain(bind_('active', Active.create(183)))
  .chain(bind_('role', Role.create('admin')))
  .map(({userId, email, active, role}) => UserSettings(userId, email, active, role))

Playground link

Lauren Yim
  • 12,700
  • 2
  • 32
  • 59