DEV Community

Patrick Roza
Patrick Roza

Posted on • Originally published at patrickroza.com on

2 1

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?"

Image of Timescale

🚀 pgai Vectorizer: SQLAlchemy and LiteLLM Make Vector Search Simple

We built pgai Vectorizer to simplify embedding management for AI applications—without needing a separate database or complex infrastructure. Since launch, developers have created over 3,000 vectorizers on Timescale Cloud, with many more self-hosted.

Read full post →

Top comments (0)

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up