1

A lot of times I notice I am struggling how to implement a pretty simple flow chart with multiple if-else conditions.

flow chart

This example looks too verbose and is not really scalable if more conditions are added later on:

import * as O from "fp-ts/lib/Option"

type Category = {
  id: string
  slug: string
}

const getCategory = (category: unknown, categories: Category[]) =>
  pipe(
    O.fromNullable(category),
    O.filter((c): c is Partial<Category> => typeof c === 'object'),
    O.chain((category): O.Option<Category> => {
      if (category?.id) {
        return O.fromNullable(categories.find((item) => item.id === category.id))
      }

      if (category?.slug) {
        return O.fromNullable(categories.find((item) => item.slug === category.slug))
      }

      return O.none
    }
  )
)

It even gets more complicated if you would replace the category list with calls to the database and also want to capture possible errors in an Either.left.

flow chart with error catching

So my question is: How should we handle one or more "else if" statements in fp-ts?

Mr.B
  • 67
  • 3
  • 9
  • You can also try `ts-pattern` for this, which gives you pattern matching powers, the functional way of if-else – Woww Apr 09 '23 at 08:31

4 Answers4

6

One function that might be helpful is alt which specifies a thunk that produces an option if the first thing in the pipe was none, but is otherwise not run. Using alt, your first example becomes:

import * as O from "fp-ts/Option";
import { pipe } from "fp-ts/function";

interface Category {
  id: string;
  slug: string;
}

declare const categories: Category[];

function getCategory(category: string | null, slug: string | null) {
  const cat = O.fromNullable(category);
  const s = O.fromNullable(slug);

  return pipe(
    cat,
    O.chain((id) => O.fromNullable(categories.find((c) => c.id === id))),
    O.alt(() =>
      pipe(
        s,
        O.chain((someSlug) =>
          O.fromNullable(categories.find((c) => c.slug === someSlug))
        )
      )
    )
  );
}

Asides:

One thing I noticed is you're filtering based on if type === "object". I'm not sure if that was to simplify what the actual code is doing, but I'd recommend using a library like io-ts for that sort of thing if you're not already.

Either also has an implementation of alt that will only run if the thing before it is a Left.

I also find working with fromNullable sort of a hassle and try to keep the fp-ts style parts of my code fp-ts-y with Option and Either types at the inputs and outputs. Doing that might help declutter some of the logic.

Souperman
  • 5,057
  • 1
  • 14
  • 39
  • 1
    Thanks man, using Alt makes it a bit cleaner! Yeah I normally use `io-ts` for this but I wanted to simplify it indeed. – Mr.B May 13 '22 at 07:10
6

Souperman’s suggestion to use alt works, but can get a little complicated once you start involving other types like Either.

You could use O.match (or O.fold which is identical) to implement the scenario in your second flowchart:

import * as E from "fp-ts/lib/Either"
import * as O from "fp-ts/lib/Option"
import {pipe} from "fp-ts/lib/function"

type Category = {
  id: string
  slug: string
}

// Functions to retrieve the category from the database
declare const getCategoryById: (id: string) => E.Either<Error, O.Option<Category>>
declare const getCategoryBySlug: (slug: string) => E.Either<Error, O.Option<Category>>

const getCategory = (category: unknown): E.Either<Error, O.Option<Category>> =>
  pipe(
    O.fromNullable(category),
    O.filter((c): c is Partial<Category> => typeof c === "object"),
    O.match(
      // If it's None, return Right(None)
      () => E.right(O.none),
      // If it's Some(category)...
      category =>
        // Retrieve the category from the database
        category?.id   ? getCategoryById(category.id)     :
        category?.slug ? getCategoryBySlug(category.slug) :
        // If there's no id or slug, return Right(None)
        E.right(O.none)
    )
  )
Lauren Yim
  • 12,700
  • 2
  • 32
  • 59
  • 1
    True, I support this if it's more your taste. Yet another option is to convert the `Option`s into `Either`s using [`fromOption`](https://gcanti.github.io/fp-ts/modules/Either.ts.html#fromoption). The reason to do that would be if you want to flatten out the error handling by making every thing `Either` and using `chain` – Souperman May 13 '22 at 02:09
  • 2
    @Souperman, that's true, but I like cherryblossoms solution more because getting an error (ex. db connection failure) and getting an empty result back are two different things. – Mr.B May 13 '22 at 13:05
1

In this case, I wouldn't complicate things by trying to "force" an fp-ts solution. You can greatly simplify your logic by just using the ternary operator:

declare const getById: (id: string) => Option<Category>
declare const getBySlug: (slug: string) => Option<Category>

const result: Option<Category> = id ? getById(id) : getBySlug(slug)

There's no need for complicated chaining of Optional stuff. If you strip out your various pipe steps into short functions and then put those function names in your pipe, you'll see the logic doesn't need to be so complicated just as an excuse to use a monad.

Although if this is truly a one or the other thing, you could also do this:

const getter: (arg: Either<Id, Slug>) => Option<Category> = E.fold(getById, getBySlug)

Either isn't just for handling errors. It's also for modeling any mutually-exclusive either-or scenario. Just pass in a Left or a Right into that function. The code is so much shorter that way, and as a bonus it's an excuse to use a monad!

user1713450
  • 1,307
  • 7
  • 18
  • 1
    Definitely getting into tangent territory here, but I've found that writing stuff the `fp-ts` way is often way more trouble than it's worth (particularly on a team where, yeah I have extensively poured through the docs and am aware of various helpers that are available in certain situations, but the rest of my team may not be). Instead, we tend to wrap our `io-ts` code in helpers that convert the `fp-ts` style to plain Javascript and don't bother with `Either`s and `Option`s. That said, if the codebase is mostly `fp-ts` I think it's better to use the `fp-ts` style for consistency. – Souperman May 13 '22 at 07:29
  • @user1713450, you are only talking about the `O.chain` part in my example, right? Because your code does not check if category exists nor if it is an object, does not handle the case where nor `id`, nor `slug` are defined. Your example isn't covering all cases. – Mr.B May 13 '22 at 13:01
  • Correct. You had part of the solution, so I gave you what I think is the improvement on the last part of your OP code. @Mr.B – user1713450 May 14 '22 at 18:07
  • Although if I were in the fp-ts ecosystem already, honestly I would use `io-ts` to do any data validation. At my company, we have production applications that use io-ts, fp-ts, and hyper-ts to handle all network communication and data validation so we know at the edges of the network the data is *always* valid. Within the application itself, then, we never have to use any `unknown`s. Only where there is file or network IO. – user1713450 May 14 '22 at 18:11
  • 1
    @Souperman yes I agree. When I first started FP, I was so focused on using all these cool monad things that I never stopped to think your standard, garden-variety ternary operator is often the best solution. Readable to everyone and is more compact than some O.doSomething, O.map, O.getOrElse, E.left, ... composition. – user1713450 May 14 '22 at 18:13
0

Like Souperman, I really like alt here and like user1713450 I like io-ts here as well. Even if the input is unknown we can define what we care about and code against that. One of the things I really like about alt is its flexibility when we need to add more conditions. Say you want to check on a new property then you just add the new alt. The getCategory function stays very readable.

import * as O from 'fp-ts/Option'
import {pipe} from 'fp-ts/function'
import * as t from 'io-ts'
import * as A from 'fp-ts/Array'

type Category = {
    id: string
    slug: string
  }

const PossibleCategory = t.union([
    t.partial({
        id:t.string,
        slug:t.string
    }),  
    t.undefined])

type PossibleCategory = t.TypeOf<typeof PossibleCategory>

const getCategory = (possibleCategory: PossibleCategory, categories: Category[]) => pipe(
    categoryById(possibleCategory, categories),
    O.alt(() => categoryBySlug(possibleCategory, categories))
)

const categoryById = (possibleCategory: PossibleCategory, categories: Category[]):O.Option<Category> => pipe(
    O.fromNullable(possibleCategory?.id),
    O.chain(id => pipe(categories, A.findFirst(c => c.id === id)))
)

const categoryBySlug =  (possibleCategory: PossibleCategory, categories: Category[]): O.Option<Category> => pipe(
    O.fromNullable(possibleCategory?.slug),
    O.chain(slug => pipe(categories, A.findFirst(c => c.slug === slug)))
)

The second scenario does make the getCategory function somewhat less readable. As mentioned by cherryblossum, it goes the fold route.

import * as O from 'fp-ts/Option'
import {pipe, flow, identity} from 'fp-ts/function'
import * as t from 'io-ts'
import * as E from 'fp-ts/Either'

type Category = {
    id: string
    slug: string
  }

const PossibleCategory = t.union([
    t.partial({
        id:t.string,
        slug:t.string
    }),  
    t.undefined])

type PossibleCategory = t.TypeOf<typeof PossibleCategory>

type GetCategory = (x:string) => E.Either<Error, O.Option<Category>>
// placeholders for db calls
const getCategoryById:GetCategory = (x:string) => E.right(O.none)
const getCategoryBySlug:GetCategory = (x:string) => E.right(O.none)

declare const categories: Category[];

const getCategory = (possibleCategory: PossibleCategory) => pipe(
    categoryById(possibleCategory),
    E.chain( 
        O.fold(
            () => categoryBySlug(possibleCategory),
            c => E.right(O.some(c))
        )
    )
)

const categoryById = (possibleCategory: PossibleCategory) => pipe(
    O.fromNullable(possibleCategory?.id),
    O.map(
        flow(
            getCategoryById, 
            E.chainOptionK(() => new Error('id not found'))(identity),
        )
    ),
    O.sequence(E.Monad),
)

const categoryBySlug = (possibleCategory:PossibleCategory)=> pipe(
    O.fromNullable(possibleCategory?.slug),
    O.map(
        flow(
            getCategoryBySlug, 
            E.chainOptionK(() => new Error('slug not found'))(identity),
        )
    ),
    O.sequence(E.Monad)
)
RobRolls
  • 498
  • 5
  • 16