More advanced pipeline composition

More advanced pipeline composition

When we add more functional composition tools to our belt, we can start composing usecase pipelines that are both terse and descriptive.

Operators

  • From previous article: map: (value => newValue) => Result<newValue, ...>
  • flatMap: (value => newResult) => newResult
  • toTup: (value => newValue) => readonly [newValue, value]
  • tee: (value => any) => Result<value, ...>
  • resultTuple: (...[Result<..., ...>]) => Result<readonly [value, value2, ...], error[]>

Sample

type CreateError = CombinedValidationError | InvalidStateError | ValidationError | ApiError | DbError

// ({ templateId: string, pax: Pax, startDate: string }) => Result<TrainTripId, CreateError>
pipe(
  flatMap(validateCreateTrainTripInfo), // R<{ pax: PaxDefinition, startDate: FutureDate, templateId: TemplateId}, CombinedValidationError>
  flatMap(toTup(({ templateId }) => getTrip(templateId))), // R<[TripWithSelectedTravelClass, { pax... }], ...>
  map(([trip, proposal]) => TrainTrip.create(proposal, trip)), // R<TrainTrip, ...>
  tee(db.trainTrips.add), // R<TrainTrip, ...>
  map(trainTrip => trainTrip.id), // R<TrainTripId, ...>
)

The validateCreateTrainTripInfo function:

// ({ templateId: string, pax: Pax, startDate: string}) => Result<({ pax: PaxDefinition, startDate: FutureDate, templateId: TemplateId }), CombinedValidationError>
pipe(
  flatMap(({ pax, startDate, templateId }) =>
    resultTuple(
      PaxDefinition.create(pax).pipe(mapErr(toFieldError("pax"))),
      FutureDate.create(startDate).pipe(mapErr(toFieldError("startDate"))),
      validateString(templateId).pipe(mapErr(toFieldError("templateId"))),
    ).pipe(mapErr(combineValidationErrors)),
  ),
  map(([pax, startDate, templateId]) => ({
    pax, startDate, templateId,
  })),
)

Both are taken from usecases/createTrainTrip.ts

This validator facilitates domain level validation, not to be confused with REST level DTO validation. It prepares the validated DTO data for input to the domain factory TrainTrip.create. These domain rules are neatly packaged in the Value objects FutureDate and PaxDefinition, reducing complexity and knowledge creep in the factory.

Again, if tc39 proposal-pipeline-operator would land, we can write more terse and beautiful code.

CombinedValidationErrors

We're wrapping each ValidationError into a FieldValidationError, so that we have the name of the field in the error context, then at the end we combine them into a single error, which can be easily examined and serialized to e.g JSON on the REST api to be consumed and examined by the client.

e.g:

if (err instanceof CombinedValidationError) {
  ctx.body = {
    fields: combineErrors(err.errors),
    message,
  }
  ctx.status = 400
}

const combineErrors = (ers: any[]) => ers.reduce((prev: any, cur) => {
  if (cur instanceof FieldValidationError) {
    if (cur.error instanceof CombinedValidationError) {
      prev[cur.fieldName] = combineErrors(cur.error.errors)
    } else {
      prev[cur.fieldName] = cur.message
    }
  }
  return prev
}, {})

Source

As always you can also find the full framework and sample source at patroza/fp-app-framework

What's Next

Next in the series, I plan to examine the question: "When to return errors, and when to throw them?"

Prefer to read or respond elsewhere?