1

I just started to learn functional programming world by using fp-ts lib. At this moment I can understand basic concept of proposed function by this lib, but I can't understand how to glue them all together in the single data flow.

I would like to share a user story that I want to implement and use it as an example for this question. It sounds like this:

  • User should be able to book an appointment of selected specialist

I know it doesn't make sense to you at this moment, but let me show you how it looks in the code to be on the same page.

Note: This is pseudo code to make it more readable

const inputData = {
  userId: 1,
  specialistId: 2,
  selectedServicesIds: ['a', 'b', 'c'],
  startTime: 'today at 12:00'
}

const user = await fetchUserById(inputData.userId)

if (user === null) {
  throw 'User not found'
}

const specialist = await fetchSpecialistById(inputData.specialistId)

if (user === null) {
  throw 'Specialist not found'
}

const workingDay = await fetchWorkingDay(inputData.specialistId, inputData.startTime)

if (workingDay === null) {
  throw 'WorkingDay not found'
}

const selectedServices = await fetchSelectedServices(inputData.specialistId, inputData.selectedServicesIds)

if (selectedServices.length < inputData.selectedServices) {
  throw 'Some selected services are not belong to the selected specialist'
}

const selectedServicesDuration = calculateDuration(selectedServices)
const appointmentEndTime = addMinutes(inputData.startTime, selectedServicesDuration)

const existingAppointments = await fetchAppointmentsOfSpeciallist(inputData.specialistId)

const isAppointmentOverlapExistingAppointments = isOverlaps(existingAppointments, inputData.startTime, appointmentEndTime)

if (isAppointmentOverlapExistingAppointments) {
  throw 'Appointment overlap existing appointments'
}

return new Appointment(inputData.userId, inputData.specialistId, ...)

As you can see this is typical imperative code:

  1. take input data
  2. fetch data from db
  3. apply validation
  4. return result

Now what I was able to achieve using fp-ts and Do-notation

  pipe(
    RTE.Do,
    RTE.apS('user', fetchUserById(args.input.clientId)),
    RTE.apSW('specialist', fetchSpecialistById(args.input.specialistId)),
    RTE.apSW('workingDay', fetchWorkingDay(args.input.specialistId, args.input.startDateTime)),
    RTE.apSW('assignedServices', getAssignedServicesOfSpecialist(args.input.specialistId, args.input.servicesIds))
    RTE.map({ user, specialist, workingDay, assignedServices } => {
       // Do I need to write all logic here? 
    })

As you can see, there are few parallel requests to fetch related data, but don't know what to do next. If I just put the imperative logic from the previous example inside RTE.map function it will look like I wrapped imperative code with some fp-ts functions.

Could you please give me an advice of how to split this into different function and how to glue them all together?

Roman Mahotskyi
  • 4,576
  • 5
  • 35
  • 68

1 Answers1

3

The key observation when refactoring code to use fp-ts is to rely on Either or TaskEither to convey information about errors. Then, via composition, you get to build more complex workflows by using simpler workflows with precise error handling since the information about possible errors is stored in the TypeScript types.

In your case, the snippet of fp-ts code is one valid way to write that logic. Each individual function you use in that pipeline (fetchUserById, fetchSpecialistById, fetchWorkingDay, getAssignedServicesOfSpecialist) should return ReaderTaskEither<R, E, A> (or just TaskEither<E, A>, since I do not see the R being used in your code). Each of them should do its own error handling. For example, fetchUserById could return a type similar to TaskEither<{ type: 'user-not-found' }, User>, where the first generic argument determines the possible error that this function can return.

If each individual function processes the data it fetches and returns either an error or that data, then in the final map you can do the last data-processing based on all the fetched information (which must be valid at this point, since TaskEither takes care of error propagation, so map will not be called if there was at least one error).

Possible enhancements

Using TaskEither instead of ReaderTaskEither

Your fp-ts snippet uses RTE, which I assume is ReaderTaskEither. It does not user the Reader part of that type, though. It always refers to args, which I assume is a variable in the parent scope. Thus, you can simplify this code and use the TaskEither type instead.

Handling errors that can arise in the last map

If the final data-combination done in map can result in some error, you may want to use taskEither.chainEitherK to return the result:

TE.chainEitherK(({ user, specialist, workingDay, assignedServices }) => {
  if (someCondition(user, specialist)) {
    return E.left({ type: 'some-error' });
  }

  // ...

  return E.right(/* ... */);
});

Reporting all errors instead of the first one

By default TaskEither will only propagate information about a single error. This happens when you use ap or chain - only the first error will be propagated in the final result.

If you have multiple parallel network calls that can fail, you may want to return all the errors instead of only the first one.

If your errors have the same type, you can use either apply.sequenceT or array.sequence to combine the results into a single Either<E[], A[]>. I like apply.sequenceT more because the result's Right case is not an array (of unknown length), but a tuple with known length and values.

As the Apply/Applicative parameter of either of these functions you could use taskEither.getApplicativeTaskValidation, which combines errors into an array.

getApplicativeTaskValidation needs to be provided a Semigroup for the array elements. The easiest case is when all errors have the same type - you can use array.getSemigroup<MyErrorType>() to get Semigroup<MyErrorType[]>.

However, usually my error types are different. I assume this may be the case here too. Thus, I developed this utility function as an alternative approach that uses an union of error types for the either.Left:

const tupleError = <E, A>(
  t: taskEither.TaskEither<E, A>
): taskEither.TaskEither<[E], A> =>
  pipe(
    t,
    taskEither.mapLeft((e) => [e])
  );

const partitionErrors = <
  T extends nonEmptyArray.NonEmptyArray<either.Either<any, any>>
>(
  results: T
) => {
  type ExtractLeft<T> = T extends either.Left<infer E> ? E : never;
  type WorkflowError = ExtractLeft<typeof results[number]>[number];
  const validation = either.getApplicativeValidation(
    array.getSemigroup<WorkflowError>()
  );
  return apply.sequenceT(validation)(...results);
};

pipe(
  apply.sequenceT(task.ApplyPar)(
    tupleError(fetchUserById(args.input.clientId)),
    tupleError(fetchSpecialistById(args.input.specialistId)),
    tupleError(
      fetchWorkingDay(args.input.specialistId, args.input.startDateTime)
    ),
    tupleError(
      getAssignedServicesOfSpecialist(
        args.input.specialistId,
        args.input.servicesId
      )
    )
  ),
  task.map(partitionErrors),
  taskEither.match(
    (errors) => {
      // TODO: handle errors
    },
    ([user, specialist, workingDay, assignedServices]) => {
      // TODO:
    }
  )
);

// Stub types
interface User {}
interface Specialist {}
interface WorkingDay {}
interface SelectedServices {}

// Your functions should return TaskEither. The first generic argument specifies the possible error type. The second argument is for the "right" value.
declare function fetchUserById(
  clientId: unknown
): taskEither.TaskEither<{ type: "user-not-found-error" }, User>;

declare function fetchSpecialistById(
  specialistId: unknown
): taskEither.TaskEither<{ type: "specialist-not-found" }, Specialist>;

declare function fetchWorkingDay(
  specialistId: unknown,
  startDateTime: unknown
): taskEither.TaskEither<{ type: "working-day-not-found" }, WorkingDay>;

declare function getAssignedServicesOfSpecialist(
  specialistId: unknown,
  servicesIds: unknown[]
): taskEither.TaskEither<
  { type: "selected-services-do-not-belong-to-specialist" },
  SelectedServices
>;

In fact, I wrote a blog post about this pattern.

Voreny
  • 765
  • 6
  • 13