gcanti / io-ts

Runtime type system for IO decoding/encoding
https://gcanti.github.io/io-ts/
MIT License
6.68k stars 330 forks source link

Help with code style using decoding, Either in async function #375

Open spacejack opened 4 years ago

spacejack commented 4 years ago

📖 Documentation

Hi, I'm revisiting io-ts after being away for a while. I'm experimenting with it in an express app and I'm not sure of the best code style approach. Here's an example:

import {Router} from 'express'
import ash from 'express-async-handler'
import {isLeft} from 'fp-ts/lib/Either'
import * as t from 'io-ts'

const ID = t.brand(
    t.number,
    (i: any): i is t.Branded<number, {readonly ID: unique symbol}> =>
        Number.isSafeInteger(i) && i >= 1,
    'ID'
)

type ID = t.TypeOf<typeof ID>

const router = Router()

router.get('/:id', ash(async (req, res) => {
    const idResult = ID.decode(Number(req.params.id))
    if (isLeft(idResult)) {
        return res.status(HttpStatus.BAD_REQUEST).json({
            message: 'Invalid User ID'
        })
    }
    const id = idResult.right
    const userResult = await getUserByID(id)
    return isRight(userResult)
        ? res.json(userResult.right)
        : res.status(HttpStatus.NOT_FOUND).json({
            message: 'User not found for this ID'
        })
}))

I looked at using pipe and fold, but the code seems to get very verbose and very indented. I also needed to use async callbacks... I wasn't sure if errors would be caught properly by Express. Is there a better way to write this?

spacejack commented 4 years ago

Also another question, why are the types not inferred here:

fold(l => {
    // l is unknown
}, r => {
    // r is unknown
})(ID.decode(input))

I think it used to work in V1, if you did T.decode(input).fold(l => {}, r => {})

giogonzo commented 4 years ago

Hi @spacejack , to answer your second question, with fp-ts v2 you'll have to use pipe together with fold to get the correct inference:

import { pipe } from 'fp-ts/lib/pipeable'
import { fold } from 'fp-ts/lib/Either'

pipe(
  ID.decode(input),
  fold(
    l => { /* l is t.Errors */ },
    r => { /* r is ID */ }
  )
)

Regarding your first question, I would say it depends on your familiarity with other fp concepts, and the willingness to use them in the codebase. For instance, mixing Promises with fp-ts data types is not really advisable in my experience, i.e. you'll keep switching between two different APIs that can't be composed (async/await or Promises in general, versus fp-ts pipes)

Just to give a quick example, If you are willing to switch from Promise<Either> to TaskEither inside this codebase, your example above could be rewritten into something like:

import * as E from "fp-ts/lib/Either"
import * as T from "fp-ts/lib/Task"
import * as TE from "fp-ts/lib/TaskEither"
import { pipe } from "fp-ts/lib/pipeable"

// provided some helpers with different signatures...

type User = { name: string }
declare function getUserById(id: ID): TE.TaskEither<HttpStatus, User>
declare function writeErrorResponse(status: HttpStatus): T.Task<void>
declare function writeSuccessResponse(body: User): T.Task<void>

// the async function body could look something similar to:

return pipe(
  ID.decode(req.params.id),
  E.mapLeft(() => HttpStatus.BAD_REQUEST),
  TE.fromEither,
  TE.chain(getUserById),
  TE.fold(writeErrorResponse, writeSuccessResponse)
)()

Just take this as a possible solution, I'm not saying you are forced to go in this direction to use io-ts. Hope this helps :)

spacejack commented 4 years ago

@giogonzo great answer! Thanks very much, it clears up a lot. So if I want to keep using async/await, it looks like isLeft/isRight make the imperative style fairly workable.

spacejack commented 4 years ago

Oh, one other question - what happens to an (unexpected) exception thrown during the piped operations?

giogonzo commented 4 years ago

I suppose by "unexpected exception" you mean throw new Error() or Promise.reject()?

fp-ts expects to work with pure function implementations - always.

For this reason, throw new Error() as well as Promise.reject() is always unexpected and unhandled by the library itself.

If dealing with 3rd parties libraries / APIs where the above is not true, explicit fp-ts APIs are available to resume the computation assuming it's pure and the above assumption holds from there onwards. As described in "Interoperability with non-functional code":