Skip to content
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

proposal: errors: add error matching utility functions #65121

Closed
chad-bekmezian-snap opened this issue Jan 16, 2024 · 3 comments
Closed

proposal: errors: add error matching utility functions #65121

chad-bekmezian-snap opened this issue Jan 16, 2024 · 3 comments
Labels
error-handling Language & library change proposals that are about error handling. Proposal
Milestone

Comments

@chad-bekmezian-snap
Copy link

Proposal Details

I am not certain this idea is at the proposal stage, but I would like to get a gauge on community interest.

I propose adding a new top level function to the errors package named Match. The function signature of Match would be Match[T any](v T, err error) MatchClause[T], which would allow the caller to frequently pass a function or method invocation inline, like so errors.Match(os.Open("test")). Most of what I find interesting about this idea is contained in MatchClause[T]. The MatchClause struct would expose to methods:

  • Cases(cases ...Case) (T, error)
  • CasesT(cases ...CaseT[T]) (T, error)

If the error provided to Match is nil, then Cases or CasesT would return immediately. Otherwise, it would iterate over the provided cases until it either reaches the end, at which point it returns the original error, or a case matches.

In my opinion, this functionality becomes most useful when there are one or more errors.As cases. It also allows for more streamlined variable assignment (in my eyes).

These new functions/types could then be used like so:

Example use case
package main

import (
   "os"
   "errors"
)

var (
   err1 = errors.New("err1")
   err2 = errors.New("err2")
)

type CustomError struct {
   Message string
   ErrCode  int
}

func (e *CustomError) Error() string {
    return e.Message
}

func main() {
   file, err := errors.Match(foo()).
      Cases(
         errors.CaseIs(err1, func(err error) error {
            return handleErr1(err)
         }),
         errors.CaseIs(err2, func(err error) error {
            return handleErr2(err)
         }),
         errors.CaseAs[*CustomError](func(err *CustomError) error {
            return handleCustomErr(err)
         }),
         errors.CaseDefault(func(err error) error {
            return handleDefault(err)
         }),
      )

   if err != nil {
      log.Fatal(err)
   }
   
   defer file.Close()
   // Use the file
}
Example implementation
package errors

 // Case represents a test case for an error.
 type Case interface {
 	// Test takes an error and returns a bool indicating if the error matches the case,
 	// and an error to replace the original error with if it does.
 	Test(error) (bool, error)
 }
 
 // CaseT represents a test case for an error.
 type CaseT[T any] interface {
 	// Test takes an error and returns a bool indicating if the error matches the case,
 	// a value of type T if it does, and an error to replace the original error with if it does.
 	Test(error) (bool, T, error)
 }
 
 type caseFunc func(error) (bool, error)
 
 func (m caseFunc) Test(err error) (bool, error) {
 	return m(err)
 }
 
 type caseTFunc[T any] func(error) (bool, T, error)
 
 func (m caseTFunc[T]) Test(err error) (bool, T, error) {
 	return m(err)
 }
 
 // CaseIs returns a Case that checks if the actual error is target, utilizing
 // [errors.Is]. If it is, the callback is invoked with the actual error.
 func CaseIs(target error, callback func(error) error) Case {
 	return caseFunc(func(err error) (bool, error) {
 		if Is(err, target) {
 			return true, callback(err)
 		}
 
 		return false, nil
 	})
 }
 
 // CaseAs returns a Case that checks if the actual error is of type T, utilizing
 // [errors.As]. If it is, the callback is invoked with the target of [errors.As].
 func CaseAs[T error](callback func(T) error) Case {
 	return caseFunc(func(err error) (bool, error) {
 		var target T
 		if As(err, &target) {
 			return true, callback(target)
 		}
 
 		return false, nil
 	})
 }
 
 // CaseDefault returns a Case that always matches and returns the result of the callback.
 // Note: If provided, this should be the last Case provided as an argument to
 // MatchClause.Cases, as any cases after this will never be tested.
 func CaseDefault(callback func(error) error) Case {
 	return caseFunc(func(err error) (bool, error) {
 		return true, callback(err)
 	})
 }
 
 // CaseIsT returns a CaseT that checks if the actual error is target, utilizing
 // [errors.Is]. If it is, the callback is invoked with the actual error.
 func CaseIsT[T any](target error, callback func(error) (T, error)) CaseT[T] {
 	return caseTFunc[T](func(err error) (bool, T, error) {
 		var v T
 		if Is(err, target) {
 			v, err = callback(err)
 			return true, v, err
 		}
 
 		return false, v, err
 	})
 }
 
 // CaseAsT returns a CaseT that checks if the actual error is of type U, utilizing
 // [errors.As]. If it is, the callback is invoked with the target of [errors.As].
 func CaseAsT[T error, U any](callback func(T) (U, error)) CaseT[U] {
 	return caseTFunc[U](func(err error) (bool, U, error) {
 		var (
 			v      U
 			target T
 		)
 		if As(err, &target) {
 			v, err = callback(target)
 			return true, v, err
 		}
 
 		return false, v, err
 	})
 }
 
 // CaseDefaultT returns a CaseT that always matches and returns the result of the callback.
 // Note: If provided, this should be the last CaseT provided as an argument to
 // MatchClause.CasesT, as any cases after this will never be tested.
 func CaseDefaultT[T any](callback func(error) (T, error)) CaseT[T] {
 	return caseTFunc[T](func(err error) (bool, T, error) {
 		v, err := callback(err)
 		return true, v, err
 	})
 }
 
 // Match is a function that takes a value and an error and returns a MatchClause[T].
 // This allows for inline/fluent error handling.
 func Match[T any](v T, err error) MatchClause[T] {
 	return MatchClause[T]{v: v, err: err}
 }
 
 type MatchClause[T any] struct {
 	v   T
 	err error
 }
 
 // Cases takes a variadic number of Case and returns a value and an error,
 // after testing each case in the order they were provided. If no case matches, the original
 // error is returned.
 func (m MatchClause[T]) Cases(cases ...Case) (T, error) {
 	if m.err == nil {
 		return m.v, nil
 	}
 
 	for _, mapper := range cases {
 		if ok, newErr := mapper.Test(m.err); ok {
 			return m.v, newErr
 		}
 	}
 
 	return m.v, m.err
 }
 
 // CasesT takes a variadic number of CaseT and returns a value and an error,
 // after testing each case in the order they were provided. If no case matches, the original
 // error is returned.
 func (m MatchClause[T]) CasesT(cases ...CaseT[T]) (T, error) {
 	if m.err == nil {
 		return m.v, nil
 	}
 
 	for _, mapper := range cases {
 		if ok, v, newErr := mapper.Test(m.err); ok {
 			return v, newErr
 		}
 	}
 
 	return m.v, m.err
 }
 ```
</details>
@gopherbot gopherbot added this to the Proposal milestone Jan 16, 2024
@seankhliao seankhliao added the error-handling Language & library change proposals that are about error handling. label Jan 16, 2024
@chad-bekmezian-snap
Copy link
Author

Admittedly the exact same example as above written as is today is this:

func main() {
	file, err := foo()
	var ce *CustomError
	switch {
	case errors.Is(err, err1):
		err = handleErr1(err)
	case errors.Is(err, err2):
		err = handleErr2(err)
	case errors.As(err, &ce):
		err = handleCustomErr(ce)
	default:
		err = handleDefault(err)
	}
	
	if err != nil {
		log.Fatal(err)
	}

	defer file.Close()
	// Use the file
}

So this is really just a matter of personal preference. I just wonder if something like this idea could get us closer to improved error handling down the road

@adonovan
Copy link
Member

Your second example is fewer lines of code, fewer concepts, and fewer tokens, and has the virtue of being supported already. For these reasons I much prefer it.

@chad-bekmezian-snap
Copy link
Author

The voting has already said enough. I'm closing this issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
error-handling Language & library change proposals that are about error handling. Proposal
Projects
None yet
Development

No branches or pull requests

4 participants