Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@samber
Copy link
Owner

@samber samber commented Aug 10, 2025

Solving #6 #11 #37 #66 #77

This PR brings 2 improvements:

  • transformation of return type when using map/flatmap/match/etc...
  • chaining of multiple transformations

Note:

  • does not fully respect FP paradigm
  • does not allow monad conversion (eg: result to monad)

I would be happy to have some feedback!

@CorentinClabaut @civilizeddev @tbflw @Luviz @axaluss @aeramu

@aeramu
Copy link

aeramu commented Aug 11, 2025

Thanks a lot for the changes! The pipe looks good to me. Try to use it, and works well.
For the transform Map, I found it has different signature with the method

func (r Result[T]) Map(mapper func(value T) (T, error)) Result[T]

And for my use case, I actually need to create this helper function and some curry function to use PipeX. But for now, I think I can go with my own helper function

// Basic Mo function for single argument (it should be not needed if Map follow the same signature as the method)
func Mo[A any, B any](f func(A) (B, error)) func(mo.Result[A]) mo.Result[B] {
	return func(r mo.Result[A]) mo.Result[B] {
		if r.IsError() {
			return mo.Err[B](r.Error())
		}
		result, err := f(r.MustGet())
		if err != nil {
			return mo.Err[B](err)
		}
		return mo.Ok(result)
	}
}

// Mo function with context - context is passed through the pipeline
func MoCtx[A any, B any](ctx context.Context, f func(context.Context, A) (B, error)) func(mo.Result[A]) mo.Result[B] {
	return func(r mo.Result[A]) mo.Result[B] {
		if r.IsError() {
			return mo.Err[B](r.Error())
		}
		result, err := f(ctx, r.MustGet())
		if err != nil {
			return mo.Err[B](err)
		}
		return mo.Ok(result)
	}
}

@taman9333
Copy link
Contributor

taman9333 commented Aug 19, 2025

I believe that in monadic chaining, we should stop on errors (short-circuit on error), but the current implementation does not do so. Does this make sense? @samber

@samber
Copy link
Owner Author

samber commented Aug 19, 2025

In the current implem, the option.Map and result.Map methods skip if the monad is empty/error.

@taman9333
Copy link
Contributor

taman9333 commented Sep 1, 2025

@samber Aha, I thought the Pipe functions themselves would implement monadic logic & short-circuiting. However, thinking about this again, I believe the implementation you have done is that Pipe functions are only for function composition & operators should use Map functions to handle errors.

I have searched for Pipe in other functional programming languages & it seems that pipe is just function composition, as you did in this PR

But it would be good if we implement another function that can implement monadic logic & short-circuit without the need to continue executing operators. For example, if this function were named sequence, it would be good if the client API could be like this:

result := sequence(
    parseInput(data),           // string → Either[Error, User]
    validateUser,               // User → Either[Error, User]  
    saveToDatabase,             // User → Either[Error, UserID]
)
// Stops at first error, propagates success through sequence

The sequence function would look like this:

func sequence(source Either[L, A], operator1, operator2, operator3) Either[L, D] {
    if source.IsLeft() {
        return source // Short-circuit on error
    }
    
    result1 := operator1(source.MustRight())
    if result1.IsLeft() {
        return result1 // Short-circuit on error
    }
    
    result2 := operator2(result1.MustRight())
    if result2.IsLeft() {
        return result2 // Short-circuit on error
    }
    
    return operator3(result2.MustRight())
}

Not sure how easy this could be implemented, but we can explore that in a separate PR if you like the idea

@taman9333
Copy link
Contributor

@samber I have another question about the Pipe function design. Should the Pipe function allow changing the Left (error) type, or should the Left type remain constant throughout the pipeline?

I was thinking that typically the Left type would stay the same since we don't often need to transform error types in a pipeline. Most of the time, we want to transform the success values (Right type) while keeping the same error type throughout.

I am not sure what the best practice is in functional programming, should pipe functions have the flexibility to change Left (error) types, or is it better to keep them constant for simplicity?

@samber
Copy link
Owner Author

samber commented Sep 1, 2025

sequence won't be able to return result1 or result2 or result3, because the types might change

if i remember well, in scala, you would write something similar to that:

result := sequence(
    parseInput(data),           // string → Either[Error, User]
    FlatMap(validateUser),               // User → Either[Error, User]  
    FlatMap(saveToDatabase),             // User → Either[Error, UserID]
)

@axaluss
Copy link

axaluss commented Sep 1, 2025

I rolled my own a while back, but based on Result[T] and a little simpler. It's great to see this lib is adding the pipeline pattern. I appreciate that your approach allows for more flexibility.

I also added basic structures for tupleN, zip, concurrent zip, some arity and function wrapping to lift into zip/pipe context.
With that I implemented a small DI lib, maybe I'll publish it when I find some time.

Are you not using gotmpl? may be helpful for maintainance.

I'm dreaming about something as powerful as https://zio.dev/reference/stream/zpipeline/

wrapperValid := gen.ParallelZip3(
		gen.AsFunc1(s.validateRankingModelRequestWrapper)(requestWrapper),
		gen.AsFunc3(s.logRequestParameters)(ctx, requestWrapper, logger),
		gen.AsFunc3(s.recordRequestMetrics)(ctx, requestWrapper, requestWrapper.ItemIDs.GetOrVal([]string{})),
	).AsNothing()

	response := gen.Pipe6(
		pipe.As(wrapperValid, pipe.AsFn(pipe.Ok(requestWrapper))),
		func(requestWrapper *RequestWrapper) pipe.Result[tuple.Tuple3[*cfg.ModelConfig, []string, *WithUserFeatures]] {
			return gen.ParallelZip3(
				gen.AsFunc1(s.findModelConfig)(requestWrapper),
				gen.AsFunc1(s.getItemIDs)(requestWrapper),
				measureServiceCall(s, ctx, requestWrapper, "requestLatency", gen.AsFunc3(s.requestUserFeatures)(ctx, logger, requestWrapper)),
			)
		},
		func(tuple tuple.Tuple3[*cfg.ModelConfig, []string, *WithUserFeatures]) pipe.Result[*WithRankingInputData] {
		.........

@taman9333
Copy link
Contributor

taman9333 commented Sep 1, 2025

@samber what about something like that

func Sequence3[A, B, C any](
  step1 mo.Result[A],
  step2 func(A) mo.Result[B],
  step3 func(B) mo.Result[C],
) mo.Result[C] {
  if step1.IsError() {
    return mo.ErrC
  }
    
  result2 := step2(step1.MustGet())
  if result2.IsError() {
    return mo.ErrC // short circuit
  }

  return step3(result2.MustGet())
}

and the client usage would be like that

result := Sequence3(
  parseInput(data),    // mo.Result[User] (from string)
  validateUser,        // User → mo.Result[User]
  saveToDatabase,      // User → mo.Result[int]
)

Another approach for flexible parameter passing

If we don't want the implementation to forcibly pass result of operator to next operator we then could use something like a context that will save result of each operator in the context and for every other operator we gonna invoke the function with that context.

The usage would be like that:

type UserContext struct {
	Config    Config
	Params    map[string]any
	Parsed    *User
	Validated *User
	SavedID   *int
}

initialContext := UserContext{
	Config: Config{DatabaseURL: "localhost"},
	Params: map[string]any{"name": "test"},
}

result := Sequence(
  initialContext,
  parseInput,
  validateUser,
  saveUser,
)

where each function gets the typed context instead of direct parameters:

func validateUser(ctx UserContext) mo.Result[UserContext] {
	user := *ctx.Parsed        // From previous step
	config := ctx.Config       // From initial context
	// validation logic...
	validated := user

	return mo.Ok(UserContext{
		Validated: &validated,
	})
}

func saveToDatabase(ctx UserContext) mo.Result[UserContext] {
	originalUser := *ctx.Parsed      // From step1
	validatedUser := *ctx.Validated  // From step2
	config := ctx.Config             // From initial context

	savedID := 12345
	return mo.Ok(UserContext{
		SavedID: &savedID,
	})
}

The implementation of Do would be:

func Do[T any](
	initialContext T,
	steps ...func(T) mo.Result[T],
) mo.Result[T] {
	context := initialContext
	for _, step := range steps {
		result := step(context)
		if result.IsError() {
  		return result // circut breaking
		}

		context = mergeContext(context, result.MustGet())
	}

	return mo.Ok(context)
}

@codecov
Copy link

codecov bot commented Sep 7, 2025

Codecov Report

❌ Patch coverage is 86.37771% with 132 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.78%. Comparing base (bccce32) to head (0ccd447).
⚠️ Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
either5/pipe.go 53.84% 60 Missing ⚠️
either/pipe.go 87.83% 18 Missing ⚠️
result/pipe.go 88.37% 15 Missing ⚠️
either/transforms.go 66.66% 12 Missing ⚠️
either5/transforms.go 69.23% 10 Missing and 2 partials ⚠️
either4/transforms.go 72.72% 7 Missing and 2 partials ⚠️
either3/transforms.go 77.77% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #78      +/-   ##
==========================================
+ Coverage   75.45%   79.78%   +4.32%     
==========================================
  Files          16       28      +12     
  Lines        1471     2439     +968     
==========================================
+ Hits         1110     1946     +836     
- Misses        338      464     +126     
- Partials       23       29       +6     
Flag Coverage Δ
unittests 79.78% <86.37%> (+4.32%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@samber
Copy link
Owner Author

samber commented Sep 25, 2025

@axaluss i don't use gotmpl. I don't really like introducing codegen into a codebase. It breaks the development flow. (just an opinion)

@taman9333 The short-circuit pattern you described does not seem suitable to me. If you want to add a MapErr method that will be executed on error only, then you need to pass through every step.


@taman9333 @axaluss I'm going to release a reactive programming lib very soon. Your interest in mo.Result pipelines makes me think you will like it. A chain of observables may have 3 states: valid/errored/completed. As soon as an error is triggered, the stream is closed in a short-circuit fashion, and no more operators are executed. Let me give you a short example of it:

obs := ro.Pipe4(
   ro.Of("hello world"),
   ro.Map(strings.ToUpper),
   ro.Filter(validate),
   ro.Map(saveToDatabase),
   ro.Retry(3),
)

obs.Subscribe(
    func(value T) {
        // on value
    },
    func(err error) {
        // on failure
    },
    func() {
        // on success
    },
)

Stay tuned ;)

@samber samber merged commit 5937bfa into master Sep 25, 2025
23 checks passed
@samber samber deleted the feat/chaining branch September 25, 2025 17:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants