Skip to content

proposal: errors: add error matching utility functions #65121

@chad-bekmezian-snap

Description

@chad-bekmezian-snap

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>

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions