Skip to content

Commit a79bb52

Browse files
author
Alejandro Mery
committed
feat: add Must/Maybe generic utilities for error handling
Add Must[T] and Maybe[T] generic functions following common Go patterns: - Must[T](value T, err error) T - panics with PanicError if err \!= nil, returns value unchanged if err == nil. Useful for tests, config loading, and cases where errors should never occur. - Maybe[T](value T, err error) T - always returns value, ignoring error. Useful when proceeding with defaults/zero values regardless of errors. Both functions work with any type T and include comprehensive test coverage using TESTING.md compliant patterns with named test types, constructor functions, and generic test helpers. Also updates README.md with documentation and examples, and adds required words to cspell dictionary. Signed-off-by: Alejandro Mery <amery@apply.co>
1 parent 612738b commit a79bb52

File tree

4 files changed

+279
-0
lines changed

4 files changed

+279
-0
lines changed

README.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,27 @@ defer func() {
290290
}()
291291
```
292292

293+
### Must/Maybe Utilities
294+
295+
Convenience functions for common error-handling patterns:
296+
297+
* `Must[T](value T, err error) T` - returns value or panics with `PanicError` if
298+
err is not nil. Follows the common Go pattern of Must* functions for cases
299+
where errors should never occur.
300+
* `Maybe[T](value T, err error) T` - always returns the value, ignoring any
301+
error. Useful when you want to proceed with a default or zero value regardless
302+
of error status.
303+
304+
```go
305+
// Must - panic on error (use in tests, config loading, etc.)
306+
config := Must(loadConfig("config.json")) // panics if loadConfig fails
307+
conn := Must(net.Dial("tcp", "localhost:8080")) // panics if dial fails
308+
309+
// Maybe - ignore errors, proceed with values
310+
content := Maybe(os.ReadFile("optional.txt")) // empty string if file missing
311+
count := Maybe(strconv.Atoi(userInput)) // zero if parsing fails
312+
```
313+
293314
### Unreachable Conditions
294315

295316
For indicating impossible code paths:

internal/build/cspell.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"addrport",
77
"addrs",
88
"analyser",
9+
"Atoi",
910
"AppendError",
1011
"AsError",
1112
"AsRecovered",
@@ -14,6 +15,7 @@
1415
"carryforward",
1516
"Codecov",
1617
"compounderror",
18+
"conn",
1719
"CompoundError",
1820
"ContextKey",
1921
"covermode",
@@ -72,6 +74,7 @@
7274
"SpinLock",
7375
"SplitName",
7476
"splithostport",
77+
"strconv",
7578
"strs",
7679
"Strs",
7780
"subpackages",

panic.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,39 @@ func Catch(fn func() error) error {
7979
var p Catcher
8080
return p.Do(fn)
8181
}
82+
83+
// Must panics if err is not nil, otherwise returns value.
84+
// This is useful for situations where errors should never occur, such as
85+
// test setup or configuration loading. It follows the common Go pattern
86+
// of Must* functions that panic on error. The panic includes proper stack
87+
// traces pointing to the caller.
88+
//
89+
// Example usage:
90+
//
91+
// config := Must(loadConfig("config.json")) // panics if loadConfig returns error
92+
// conn := Must(net.Dial("tcp", "localhost:8080")) // panics if dial fails
93+
// data := Must(json.Marshal(obj)) // panics if marshal fails
94+
func Must[T any](value T, err error) T {
95+
if err != nil {
96+
panic(NewPanicWrap(1, err, "core.Must"))
97+
}
98+
return value
99+
}
100+
101+
// Maybe returns the value, ignoring any error.
102+
// This is useful when you want to proceed with a default or zero value
103+
// regardless of whether an error occurred. Unlike Must, it never panics.
104+
//
105+
// Example usage:
106+
//
107+
// // Use empty string if ReadFile fails
108+
// content := Maybe(os.ReadFile("optional.txt"))
109+
//
110+
// // Use zero value if parsing fails
111+
// count := Maybe(strconv.Atoi(userInput))
112+
//
113+
// // Chain operations where errors are non-critical
114+
// data := Maybe(json.Marshal(obj))
115+
func Maybe[T any](value T, _ error) T {
116+
return value
117+
}

panic_test.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package core
22

33
import (
44
"errors"
5+
"fmt"
56
"testing"
67
)
78

@@ -441,3 +442,221 @@ func TestCatchWithPanicRecovery(t *testing.T) {
441442
t.Run(tc.name, tc.test)
442443
}
443444
}
445+
446+
// testMust is a helper to test Must function by catching panics.
447+
// It wraps Must calls in panic recovery to allow testing both success
448+
// and panic scenarios. Returns the value and any recovered panic as an error.
449+
func testMust[T any](v0 T, e0 error) (v1 T, e1 error) {
450+
defer func() {
451+
if e2 := AsRecovered(recover()); e2 != nil {
452+
e1 = e2
453+
}
454+
}()
455+
456+
v1 = Must(v0, e0)
457+
return v1, nil
458+
}
459+
460+
// mustSuccessTestCase tests Must function success scenarios where no panic should occur.
461+
type mustSuccessTestCase struct {
462+
// Large fields first - interfaces (8 bytes on 64-bit)
463+
value any
464+
err error
465+
466+
// Small fields last - strings (16 bytes on 64-bit)
467+
name string
468+
}
469+
470+
// newMustSuccessTestCase creates a new mustSuccessTestCase with the given parameters.
471+
// For success cases, err is always nil.
472+
func newMustSuccessTestCase(name string, value any) mustSuccessTestCase {
473+
return mustSuccessTestCase{
474+
value: value,
475+
err: nil,
476+
name: name,
477+
}
478+
}
479+
480+
// test validates that Must returns the value unchanged when err is nil.
481+
func (tc mustSuccessTestCase) test(t *testing.T) {
482+
t.Helper()
483+
484+
tc.testMustWithValue(t)
485+
}
486+
487+
// testMustT is a generic test helper for Must function with comparable types.
488+
// It handles the common pattern of testing Must with a value and verifying
489+
// the result matches expectations.
490+
func testMustT[V comparable](t *testing.T, tc mustSuccessTestCase, value V) {
491+
t.Helper()
492+
493+
got, err := testMust(value, tc.err)
494+
AssertNoError(t, err, "Must success")
495+
AssertEqual(t, value, got, "Must value")
496+
}
497+
498+
// testMustSlice is a specialized test helper for Must function with slice types.
499+
func testMustSlice[V any](t *testing.T, tc mustSuccessTestCase, value []V) {
500+
t.Helper()
501+
502+
got, err := testMust(value, tc.err)
503+
AssertNoError(t, err, "Must success")
504+
AssertSliceEqual(t, value, got, "Must slice")
505+
}
506+
507+
// testMustWithValue dispatches to the appropriate test helper.
508+
func (tc mustSuccessTestCase) testMustWithValue(t *testing.T) {
509+
t.Helper()
510+
511+
// Test with different types using type switches
512+
switch v := tc.value.(type) {
513+
case string:
514+
testMustT(t, tc, v)
515+
case int:
516+
testMustT(t, tc, v)
517+
case bool:
518+
testMustT(t, tc, v)
519+
case []int:
520+
testMustSlice(t, tc, v)
521+
case *int:
522+
testMustT(t, tc, v)
523+
case struct{ Name string }:
524+
testMustT(t, tc, v)
525+
default:
526+
t.Fatalf("unsupported test value type: %T", tc.value)
527+
}
528+
}
529+
530+
func TestMustSuccess(t *testing.T) {
531+
testCases := []mustSuccessTestCase{
532+
newMustSuccessTestCase("string success", "hello"),
533+
newMustSuccessTestCase("int success", 42),
534+
newMustSuccessTestCase("bool success", true),
535+
newMustSuccessTestCase("slice success", S(1, 2, 3)),
536+
newMustSuccessTestCase("nil pointer success", (*int)(nil)),
537+
newMustSuccessTestCase("struct success", struct{ Name string }{"test"}),
538+
}
539+
540+
for _, tc := range testCases {
541+
t.Run(tc.name, tc.test)
542+
}
543+
}
544+
545+
// mustPanicTestCase tests Must function panic scenarios where Must should panic.
546+
type mustPanicTestCase struct {
547+
// Large fields first - error interface (8 bytes)
548+
err error
549+
550+
// Small fields last - string (16 bytes)
551+
name string
552+
}
553+
554+
// test validates that Must panics with proper PanicError when err is not nil.
555+
func (tc mustPanicTestCase) test(t *testing.T) {
556+
t.Helper()
557+
558+
_, err := testMust("value", tc.err)
559+
AssertError(t, err, "Must panic")
560+
561+
// Verify the panic contains our original error
562+
AssertTrue(t, errors.Is(err, tc.err), "panic wraps original")
563+
564+
// Verify it's a proper PanicError
565+
panicErr, ok := AssertTypeIs[*PanicError](t, err, "panic type")
566+
if ok {
567+
// Verify stack trace exists
568+
stack := panicErr.CallStack()
569+
AssertTrue(t, len(stack) > 0, "has stack trace")
570+
}
571+
}
572+
573+
func TestMustPanic(t *testing.T) {
574+
testCases := []mustPanicTestCase{
575+
{
576+
name: "simple error",
577+
err: errors.New("test error"),
578+
},
579+
{
580+
name: "formatted error",
581+
err: fmt.Errorf("formatted error: %d", 42),
582+
},
583+
{
584+
name: "wrapped error",
585+
err: fmt.Errorf("wrapped: %w", errors.New("inner")),
586+
},
587+
}
588+
589+
for _, tc := range testCases {
590+
t.Run(tc.name, tc.test)
591+
}
592+
}
593+
594+
type maybeTestCase struct {
595+
// Large fields first - interfaces (8 bytes)
596+
value any
597+
err error
598+
599+
// Small fields last - string (16 bytes)
600+
name string
601+
}
602+
603+
func (tc maybeTestCase) test(t *testing.T) {
604+
t.Helper()
605+
606+
// Test with different types using type switches
607+
switch v := tc.value.(type) {
608+
case string:
609+
got := Maybe(v, tc.err)
610+
AssertEqual(t, v, got, "Maybe string")
611+
case int:
612+
got := Maybe(v, tc.err)
613+
AssertEqual(t, v, got, "Maybe int")
614+
case *int:
615+
got := Maybe(v, tc.err)
616+
AssertEqual(t, v, got, "Maybe pointer")
617+
case struct{ Name string }:
618+
got := Maybe(v, tc.err)
619+
AssertEqual(t, v, got, "Maybe struct")
620+
default:
621+
t.Fatalf("unsupported test value type: %T", tc.value)
622+
}
623+
}
624+
625+
func TestMaybe(t *testing.T) {
626+
testCases := []maybeTestCase{
627+
{
628+
name: "string with nil error",
629+
value: "hello",
630+
err: nil,
631+
},
632+
{
633+
name: "string with error",
634+
value: "world",
635+
err: errors.New("ignored error"),
636+
},
637+
{
638+
name: "int with nil error",
639+
value: 42,
640+
err: nil,
641+
},
642+
{
643+
name: "int with error",
644+
value: 0,
645+
err: errors.New("another ignored error"),
646+
},
647+
{
648+
name: "nil pointer with error",
649+
value: (*int)(nil),
650+
err: errors.New("pointer error"),
651+
},
652+
{
653+
name: "struct with error",
654+
value: struct{ Name string }{"test"},
655+
err: fmt.Errorf("formatted: %d", 123),
656+
},
657+
}
658+
659+
for _, tc := range testCases {
660+
t.Run(tc.name, tc.test)
661+
}
662+
}

0 commit comments

Comments
 (0)