-
-
Notifications
You must be signed in to change notification settings - Fork 106
feat: adding option/result/either/eitherX packages, with basic operations and pipelining #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Thanks a lot for the changes! The pipe looks good to me. Try to use it, and works well. 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 // 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)
}
} |
|
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 |
|
In the current implem, the |
|
@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 sequenceThe 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 |
|
@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? |
|
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]
) |
|
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. 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] {
......... |
|
@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 passingIf 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 Report❌ Patch coverage is
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
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
|
@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 @taman9333 @axaluss I'm going to release a reactive programming lib very soon. Your interest in 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 ;) |
a6d9369 to
612131d
Compare
612131d to
0ccd447
Compare
Solving #6 #11 #37 #66 #77
This PR brings 2 improvements:
Note:
I would be happy to have some feedback!
@CorentinClabaut @civilizeddev @tbflw @Luviz @axaluss @aeramu