Reputation: 193
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('[email protected]').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('[email protected]')
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?
Upvotes: 1
Views: 864
Reputation: 14148
You could use something like this:
const userSettings = Right({})
.chain(acc => UserId.create('1234').map(userId => ({...acc, userId})))
.chain(acc => Email.create('[email protected]').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('[email protected]')))
.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('[email protected]')))
.chain(bind_('active', Active.create(183)))
.chain(bind_('role', Role.create('admin')))
.map(({userId, email, active, role}) => UserSettings(userId, email, active, role))
Upvotes: 0