aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2025-07-13 00:41:51 +0200
committerStefan Majewsky <majewsky@gmx.net>2025-07-13 00:41:51 +0200
commit4dece322c205eebd8eb9e391817e2b7af223fc08 (patch)
tree391befbf75a8cd1cbeb79eb7eedaf730194ffaa8
parent9fede8ef986e4bcf8a0b461075fcc13c0fc33c11 (diff)
downloadgo-gg-4dece322c205eebd8eb9e391817e2b7af223fc08.tar.gz
refined: add type Scalarrefinement-types-4
-rw-r--r--.golangci.yaml1
-rw-r--r--internal/test/helpers.go21
-rw-r--r--option/option.go8
-rw-r--r--refined/scalar.go329
-rw-r--r--refined/scalar_test.go213
-rw-r--r--refined/shared.go76
-rw-r--r--refined/struct.go2
-rw-r--r--refined/validate_unmarshaled.go181
8 files changed, 823 insertions, 8 deletions
diff --git a/.golangci.yaml b/.golangci.yaml
index 291e557..4b9fb44 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -42,6 +42,7 @@ linters:
goconst:
min-occurrences: 5
gocritic:
+ disable-all: true
enabled-checks:
- boolExprSimplify
- builtinShadow
diff --git a/internal/test/helpers.go b/internal/test/helpers.go
index a93aa9e..fc52074 100644
--- a/internal/test/helpers.go
+++ b/internal/test/helpers.go
@@ -19,6 +19,27 @@ func AssertEqual[V any](t *testing.T, actual, expected V) {
t.Errorf("expected %#v, but got %#v", expected, actual)
}
+func AssertErrorEqual(t *testing.T, actual error, expected string) {
+ t.Helper()
+ if actual == nil {
+ t.Errorf("expected err = %q, but got nil", expected)
+ } else if actual.Error() != expected {
+ t.Errorf("expected err = %q, but got %q", expected, actual.Error())
+ }
+}
+
+func AssertPanics(t *testing.T, action func()) (recovered any) {
+ t.Helper()
+ defer func() {
+ recovered = recover()
+ if recovered == nil {
+ t.Error("action did not panic as expected")
+ }
+ }()
+ action()
+ return
+}
+
func PointerTo[V any](value V) *V {
return &value
}
diff --git a/option/option.go b/option/option.go
index a96e87d..776b16a 100644
--- a/option/option.go
+++ b/option/option.go
@@ -22,11 +22,11 @@
//
// # Marshalling considerations
//
-// Marshaling into and from YAML using https://github.com/go-yaml/yaml is supported.
+// Marshaling into and from YAML using https://github.com/go-yaml/yaml (or one of its many forks) is supported.
// The "omitempty" flag works as expected.
//
// Marshaling into and from JSON using encoding/json is supported, but the "omitempty" flag does not work.
-// You must use the "omitzero" flag to get the same effect, but note that this flag is only supported by Go 1.24 and newer.
+// You must use the "omitzero" flag to get the same effect.
//
// # How to replace pointer types with Option types
//
@@ -367,7 +367,7 @@ func (o *Option[T]) UnmarshalJSON(buf []byte) error {
return nil
}
-// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3.
+// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3 as well as their forks.
func (o Option[T]) MarshalYAML() (any, error) {
if o.isSome {
// If we just return o.value directly here, MarshalYAML will not be called
@@ -385,7 +385,7 @@ func (o Option[T]) MarshalYAML() (any, error) {
// UnmarshalYAML implements the yaml.Unmarshaler interface from gopkg.in/yaml.v2.
//
-// gopkg.in/yaml.v3 supports this interface via backwards-compatibility,
+// gopkg.in/yaml.v3 and its forks support this interface via backwards-compatibility,
// so we intentionally do not use the v3-only signature that refers to the yaml.Node type.
func (o *Option[T]) UnmarshalYAML(unmarshal func(any) error) error {
var data *T
diff --git a/refined/scalar.go b/refined/scalar.go
index b7099d6..f5fb650 100644
--- a/refined/scalar.go
+++ b/refined/scalar.go
@@ -3,11 +3,332 @@
package refined
-import . "github.com/majewsky/gg/option"
+import (
+ "database/sql"
+ "database/sql/driver"
+ "encoding/json"
+ "fmt"
-type Scalar struct {
- value Option[any]
+ . "github.com/majewsky/gg/option"
+)
+
+// Scalar provides support for refinement types derived from scalar base types (individual numbers or strings).
+// This type is used to declare a refinement type, by placing a reflect.Scalar type inside a struct type as an embedded field:
+//
+// type AccountName struct {
+// refined.Scalar[AccountName, string]
+// }
+//
+// Like in this example, the first type argument of Scalar (S) is always the outer struct type, and the second type argument (V) is the base type.
+// The construction using an embedded field on a struct type allows you to define additional convenience methods in the same way that you would on a newtype,
+// while also allowing the struct type to expose method implementations provided by type Scalar (e.g. MarshalJSON):
+//
+// // using a newtype
+// type AccountName string
+// func (n AccountName) IsReserved() bool {
+// return strings.HasPrefix(string(n), "__")
+// }
+//
+// // using a refinement type instead
+// type AccountName struct {
+// refined.Scalar[AccountName, string]
+// }
+// func (n AccountName) IsReserved() bool {
+// // retrieves the underlying value using the Unpack method of type Scalar
+// return strings.HasPrefix(n.Unpack(), "__")
+// }
+//
+// To make the refinement type work, type Scalar needs to know the predicate that applies to this type.
+// This is why the struct type is given to type Scalar as the type argument S.
+// The struct type must implement the IsAScalar interface. Continuing the example:
+//
+// var accountNameRx = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+//
+// func (AccountName) RefinedMatch(value string) bool {
+// // This function decides which values are acceptable for the refinement type.
+// return accountNameRx.MatchString(value)
+// }
+// func (AccountName) RefinedBuild(s refined.PreScalar[AccountName, string]) AccountName {
+// // This function allows the library to cast a bare Scalar instance into the full struct type.
+// return AccountName{refined.PromoteScalar(s)}
+// }
+//
+// Marshaling into and from YAML using https://github.com/go-yaml/yaml (or one of its many forks) is supported.
+// The "omitempty" flag works as expected.
+//
+// Marshaling into and from JSON using encoding/json is supported, but the "omitempty" flag does not work.
+// You must use the "omitzero" flag to get the same effect.
+type Scalar[S IsAScalar[S, V], V ScalarValue] struct {
+ value Option[V]
+}
+
+// ScalarValue is an interface that is implemented by all scalar types that do not allow interior mutation.
+// That is to say, any operation on a type in this interface must always yield a fresh value without editing the inside of the existing value.
+// For example, []byte is not in this interface because edits made on a copy of a []byte value can affect the original:
+//
+// original := []byte("foo")
+// copied := original
+// copied[1] = 'x'
+// fmt.Println(string(original)) // prints "fxo", not "foo"!
+type ScalarValue interface {
+ ~bool |
+ ~int | ~int8 | ~int16 | ~int32 | ~int64 |
+ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
+ ~float32 | ~float64 |
+ ~complex64 | ~complex128 |
+ ~string
+}
+
+// IsAScalar is an interface that must be implemented by user-defined struct types holding an instance of type Scalar.
+// See documentation on type Scalar for an explanation and example of how these two types are interconnected.
+type IsAScalar[S any, V ScalarValue] interface {
+ // RefinedMatch implements the predicate for the refinement type.
+ // Given a value of the scalar base type, this function shall return whether that value is acceptable for the refinement type.
+ // If false is returned, package refined will ensure that no instance of Scalar[S, V] with that value is ever constructed.
+ //
+ // For technical reasons, this function is a method on type S, but the implementation shall not use the receiver value.
+ // Package refined will always call this method on an instance of S that is invalid and not ready to use other than for calling this method.
+ RefinedMatch(V) bool
+
+ // RefinedBuild allows package refined to cast a bare Scalar instance into the full user-defined struct type.
+ // Instead of a Scalar instance, this method receives
+ //
+ // For technical reasons, this function is a method on type S, but the implementation shall not use the receiver value.
+ // Package refined will always call this method on an instance of S that is invalid and not ready to use other than for calling this method.
+ // The implementation shall always return a freshly constructed instance of type S that is not related to the receiver value.
+ RefinedBuild(PreScalar[S, V]) S
+}
+
+// PreScalar is like Scalar, but with a weaker type bound.
+//
+// This type is only needed in one place, in the declaration of RefinedBuild(),
+// to break a dependency cycle between the definition of type Scalar and type IsAScalar.
+//
+// It is not proper to construct instances of this type outside of package refined.
+// Attempting to use a zero-initialized value of this type will result in a panic.
+type PreScalar[S any, V ScalarValue] struct {
+ value Option[V]
+}
+
+// PromoteScalar converts a PreScalar into a Scalar, thus strengthening its type bound.
+//
+// This function is only needed in one place, in implementations of RefinedBuild(),
+// to break a dependency cycle between the definition of type Scalar and type IsAScalar.
+func PromoteScalar[S IsAScalar[S, V], V ScalarValue](p PreScalar[S, V]) Scalar[S, V] {
+ if p.value.IsNone() {
+ // `value = None()` only occurs when user code outside of package refined
+ // creates a zero-valued instance of PreScalar like this:
+ //
+ // var ps refined.PreScalar[S, V]
+ // s := refined.PromoteScalar(ps)
+ //
+ // or through reflection. This is illegal. Only PreScalar instances
+ // constructed within package refined are legal to use because package
+ // refined will ensure that the predicate of S is upheld.
+ panic("PromoteScalar received an illegally constructed PreScalar instance")
+ }
+ return Scalar[S, V](p)
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// generic methods on Scalar[S, V]
+
+// refinedSeal implements the isARefinementType interface.
+func (s Scalar[S, V]) refinedSeal() seal {
+ // This method is never called. It's only part of the interface to make it unimplementable outside this package.
+ return seal{}
+}
+
+// refinedNew implements the isARefinementType interface.
+func (s Scalar[S, V]) refinedNew(value V) (S, error) {
+ var empty S
+ err := checkScalarValue[S, V](value)
+ if err == nil {
+ return empty.RefinedBuild(PreScalar[S, V]{Some(value)}), nil
+ } else {
+ return empty, err
+ }
+}
+
+func checkScalarValue[S IsAScalar[S, V], V ScalarValue](value V) error {
+ var empty S
+ if empty.RefinedMatch(value) {
+ return nil
+ } else {
+ return fmt.Errorf("value %#v is not acceptable for %T", value, empty)
+ }
+}
+
+// IsValid returns whether the scalar holds a valid value.
+// This will only ever be false for zero-valued Scalar instances:
+//
+// type AccountName struct {
+// refined.Scalar[AccountName, string]
+// }
+//
+// name1, err := refined.New[AccountName]("example")
+// if err == nil {
+// fmt.Println(name1.IsValid()) // prints true
+// }
+//
+// var name2 AccountName // not initialized to a valid value!
+// fmt.Println(name2.IsValid()) // prints false
+//
+// type AccountData struct {
+// ID int64
+// Name AccountName
+// }
+// data := AccountData {
+// ID: 42,
+// // Name is not initialized!
+// }
+// fmt.Println(data.Name.IsValid()) // prints false
+//
+// Most functions handling refinement types should not have to call this method.
+// Access the refinement type's value directly through Unpack() or any other method on Scalar.
+// If that panics, you should be catching that in a test.
+//
+// The most common situation where IsValid() truly needs to be used is when unmarshaling data structures from JSON or similar formats.
+// Unmarshalers often leave struct fields unfilled if they are not mentioned in the data, and package refined cannot intervene in that
+// because technically nothing gets unmarshaled into the Scalar value and so no code is executed. For example:
+//
+// var accountData struct {
+// ID int64
+// Name AccountName
+// }
+// input := `{"ID":42}` // "Name" key is missing!
+// err := json.Unmarshal([]byte(input), &accountData) // will succeed (err == nil)
+// fmt.Println(accountData.Name.IsValid()) // prints false
+//
+// When unmarshaling data structures that contain refinement type values, use func ValidateUnmarshaled instead of this method.
+func (s Scalar[S, V]) IsValid() bool {
+ return s.value.IsSome()
+}
+
+// Unpack returns a copy of the raw value inside this scalar.
+// Panics if called on a zero value; see func IsValid for details.
+func (s Scalar[S, V]) Unpack() V {
+ return s.value.UnwrapOrPanic("Unpack() called on an illegally constructed Scalar instance")
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// formatting/marshalling support for Scalar[S, V]
+
+// Format implements the fmt.Formatter interface.
+//
+// For most verbs, the contained value will be formatted as if it was given directly.
+// For %v, the %v representation of the contained value is wrapped in "TypeName[]".
+// For %#v, a refined.Literal() invocation is formatted.
+func (s Scalar[S, V]) Format(f fmt.State, verb rune) {
+ val := s.Unpack()
+ if verb != 'v' {
+ fmt.Fprintf(f, fmt.FormatString(f, verb), val)
+ return
+ }
+
+ var empty S
+ sName := fmt.Sprintf("%T", empty)
+ inner := fmt.Sprintf(fmt.FormatString(f, verb), val)
+ if f.Flag('#') {
+ fmt.Fprintf(f, "refined.Literal[%s](%s)", sName, inner)
+ } else {
+ fmt.Fprintf(f, "%s[%s]", sName, inner)
+ }
+}
+
+// Value implements the database/sql/driver.Valuer interface.
+//
+// If you want to get the contained value, use Unpack().
+// This name is unfortunately taken by an interface from the standard library.
+func (s Scalar[S, V]) Value() (driver.Value, error) {
+ return driver.DefaultParameterConverter.ConvertValue(s.Unpack())
+}
+
+// Scan implements the database/sql.Scanner interface.
+func (s *Scalar[S, V]) Scan(src any) error {
+ // We cannot scan `src` into V directly because the required function (database/sql.convertAssign) is private.
+ // sql.Null[V].Scan() is the next best thing, but it allows `src = nil` even though no possible choice for V does,
+ // so we need to catch this case ourselves.
+ var (
+ data sql.Null[V]
+ empty S
+ )
+ err := data.Scan(src)
+ if err != nil {
+ return err
+ }
+
+ if !data.Valid {
+ // this mimics the error message that database/sql would generate when scanning `src = nil` into V directly
+ return fmt.Errorf("unsupported Scan, storing driver.Value type %T into type %T", src, empty)
+ }
+
+ err = checkScalarValue[S, V](data.V)
+ if err == nil {
+ *s = Scalar[S, V]{Some(data.V)}
+ }
+ return err
+}
+
+// IsZero implements the IsZeroer interface as understood by encoding/json and github.com/go-yaml/yaml.
+// It returns whether the underlying value is zero.
+func (s Scalar[S, V]) IsZero() bool {
+ value := s.Unpack()
+ var zero V
+ return value == zero
+}
+
+// MarshalJSON implements the encoding/json.Marshaler interface.
+func (s Scalar[S, V]) MarshalJSON() ([]byte, error) {
+ return json.Marshal(s.Unpack())
+}
+
+// UnmarshalJSON implements the encoding/json.Unmarshaler interface.
+func (s *Scalar[S, V]) UnmarshalJSON(buf []byte) error {
+ var raw V
+ err := json.Unmarshal(buf, &raw)
+ if err != nil {
+ return err
+ }
+ err = checkScalarValue[S, V](raw)
+ if err == nil {
+ *s = Scalar[S, V]{Some(raw)}
+ }
+ return err
+}
+
+type yamlMarshaler interface {
+ MarshalYAML() (any, error)
+}
+
+// MarshalYAML implements the yaml.Marshaler interface from gopkg.in/yaml.v2 and v3 as well as their forks.
+func (s Scalar[S, V]) MarshalYAML() (any, error) {
+ value := s.Unpack()
+ // If we just return `value` directly here, MarshalYAML will not be called
+ // on the value even if it exists. For this one specific case, we have to
+ // take care ourselves.
+ if m, ok := any(value).(yamlMarshaler); ok {
+ return m.MarshalYAML()
+ } else {
+ return value, nil
+ }
+}
+
+// UnmarshalYAML implements the yaml.Unmarshaler interface from gopkg.in/yaml.v2.
+//
+// gopkg.in/yaml.v3 and its forks support this interface via backwards-compatibility,
+// so we intentionally do not use the v3-only signature that refers to the yaml.Node type.
+func (s *Scalar[S, V]) UnmarshalYAML(unmarshal func(any) error) error {
+ var raw V
+ err := unmarshal(&raw)
+ if err != nil {
+ return err
+ }
+ err = checkScalarValue[S, V](raw)
+ if err == nil {
+ *s = Scalar[S, V]{Some(raw)}
+ }
+ return err
}
// TODO: func ValidateUnmarshaled()
-// TODO: constrain base type of Scalar to be a scalar via an explicit type enum
diff --git a/refined/scalar_test.go b/refined/scalar_test.go
new file mode 100644
index 0000000..281b74e
--- /dev/null
+++ b/refined/scalar_test.go
@@ -0,0 +1,213 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package refined_test
+
+import (
+ "database/sql"
+ "database/sql/driver"
+ "encoding/json"
+ "fmt"
+ "regexp"
+ "testing"
+
+ . "github.com/majewsky/gg/internal/test"
+ "github.com/majewsky/gg/refined"
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// example refinement types
+
+type AccountName struct {
+ refined.Scalar[AccountName, string]
+}
+
+var accountNameRx = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
+
+func (AccountName) RefinedMatch(value string) bool {
+ return accountNameRx.MatchString(value)
+}
+func (AccountName) RefinedBuild(s refined.PreScalar[AccountName, string]) AccountName {
+ return AccountName{refined.PromoteScalar(s)}
+}
+
+type DiceRoll struct {
+ refined.Scalar[DiceRoll, uint8]
+}
+
+func (DiceRoll) RefinedMatch(value uint8) bool {
+ return value >= 1 && value <= 6
+}
+func (DiceRoll) RefinedBuild(s refined.PreScalar[DiceRoll, uint8]) DiceRoll {
+ return DiceRoll{refined.PromoteScalar(s)}
+}
+
+// We need this for a test that specifically uses complex numbers.
+type PurelyImaginary struct {
+ refined.Scalar[PurelyImaginary, complex128]
+}
+
+func (PurelyImaginary) RefinedMatch(value complex128) bool {
+ return real(value) == 0
+}
+func (PurelyImaginary) RefinedBuild(s refined.PreScalar[PurelyImaginary, complex128]) PurelyImaginary {
+ return PurelyImaginary{refined.PromoteScalar(s)}
+}
+
+// We need this for a test involving serialization of zero values to JSON.
+type EmptyString struct {
+ refined.Scalar[EmptyString, string]
+}
+
+func (EmptyString) RefinedMatch(value string) bool {
+ return value == ""
+}
+func (EmptyString) RefinedBuild(s refined.PreScalar[EmptyString, string]) EmptyString {
+ return EmptyString{refined.PromoteScalar(s)}
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// static assertions that refined.Scalar implements the intended interfaces
+// (The YAML interfaces are not checked because we don't want to add 3rd-party lib deps here.)
+
+var (
+ _ fmt.Formatter = AccountName{}
+ _ driver.Valuer = AccountName{}
+ _ sql.Scanner = &AccountName{}
+ _ json.Marshaler = AccountName{}
+ _ json.Unmarshaler = &AccountName{}
+)
+
+////////////////////////////////////////////////////////////////////////////////
+// basic API
+
+func TestNewOnScalar(t *testing.T) {
+ value, err := refined.New[AccountName]("")
+ AssertErrorEqual(t, err, `value "" is not acceptable for refined_test.AccountName`)
+ AssertEqual(t, value.IsValid(), false)
+
+ value, err = refined.New[AccountName]("a+b")
+ AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`)
+ AssertEqual(t, value.IsValid(), false)
+
+ value, err = refined.New[AccountName]("ab")
+ AssertEqual(t, err, nil)
+ AssertEqual(t, value.IsValid(), true)
+ AssertEqual(t, value.Unpack(), "ab")
+}
+
+func TestLiteralOnScalar(t *testing.T) {
+ message := AssertPanics(t, func() { _ = refined.Literal[AccountName]("") })
+ AssertEqual(t, message, `value "" is not acceptable for refined_test.AccountName`)
+
+ message = AssertPanics(t, func() { _ = refined.Literal[AccountName]("a+b") })
+ AssertEqual(t, message, `value "a+b" is not acceptable for refined_test.AccountName`)
+
+ value := refined.Literal[AccountName]("ab")
+ AssertEqual(t, value.IsValid(), true)
+ AssertEqual(t, value.Unpack(), "ab")
+}
+
+func TestIllegalScalarOperations(t *testing.T) {
+ var emptyScalar AccountName
+ AssertEqual(t, emptyScalar.IsValid(), false)
+ AssertPanics(t, func() { emptyScalar.Unpack() })
+
+ var emptyPreScalar refined.PreScalar[AccountName, string]
+ AssertPanics(t, func() { _ = refined.PromoteScalar(emptyPreScalar) })
+}
+
+func TestComparisonOnScalar(t *testing.T) {
+ n1 := refined.Literal[AccountName]("same")
+ n2 := refined.Literal[AccountName]("different")
+ n3 := refined.Literal[AccountName]("same")
+ AssertEqual(t, n1 == n2, false)
+ AssertEqual(t, n1 == n3, true)
+}
+
+////////////////////////////////////////////////////////////////////////////////
+// interface implementations
+
+func TestFormatterOnScalar(t *testing.T) {
+ name := refined.Literal[AccountName]("abc")
+ AssertEqual(t, fmt.Sprintf("%s", name), `abc`)
+ AssertEqual(t, fmt.Sprintf("%q", name), `"abc"`)
+ AssertEqual(t, fmt.Sprintf("%v", name), `refined_test.AccountName[abc]`)
+ AssertEqual(t, fmt.Sprintf("%#v", name), `refined.Literal[refined_test.AccountName]("abc")`)
+
+ roll := refined.Literal[DiceRoll](4)
+ AssertEqual(t, fmt.Sprintf("%d", roll), `4`)
+ AssertEqual(t, fmt.Sprintf("%02d", roll), `04`)
+ AssertEqual(t, fmt.Sprintf("%v", roll), `refined_test.DiceRoll[4]`)
+ AssertEqual(t, fmt.Sprintf("%#v", roll), `refined.Literal[refined_test.DiceRoll](0x4)`)
+}
+
+func TestMarshalSQLOnScalar(t *testing.T) {
+ value, err := refined.Literal[AccountName]("hello").Value()
+ AssertEqual(t, err, nil)
+ AssertEqual(t, value, "hello")
+
+ // complex64 and complex128 are the only types matching ScalarValue that can fail conversion into an SQL value
+ _, err = refined.Literal[PurelyImaginary](5i).Value()
+ AssertErrorEqual(t, err, `unsupported type complex128, a complex128`)
+}
+
+func TestUnmarshalSQLOnScalar(t *testing.T) {
+ var n1 AccountName
+ err := n1.Scan("example")
+ AssertEqual(t, err, nil)
+ AssertEqual(t, n1.Unpack(), "example")
+
+ var n2 AccountName
+ err = n2.Scan("a+b")
+ AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`)
+ AssertEqual(t, n2.IsValid(), false)
+
+ var n3 AccountName
+ err = n3.Scan(nil)
+ AssertErrorEqual(t, err, `unsupported Scan, storing driver.Value type <nil> into type refined_test.AccountName`)
+ AssertEqual(t, n3.IsValid(), false)
+}
+
+func TestMarshalJSONOnScalar(t *testing.T) {
+ data := struct {
+ // test for serializing non-zero values
+ N AccountName `json:"n"`
+ // tests for serializing zero values
+ E1 EmptyString `json:"e1"`
+ E2 EmptyString `json:"e2,omitempty"`
+ E3 EmptyString `json:"e3,omitzero"`
+ }{
+ N: refined.Literal[AccountName]("foo"),
+ E1: refined.Literal[EmptyString](""),
+ E2: refined.Literal[EmptyString](""),
+ E3: refined.Literal[EmptyString](""),
+ }
+ buf, err := json.Marshal(data)
+ AssertEqual(t, err, nil)
+ AssertEqual(t, string(buf), `{"n":"foo","e1":"","e2":""}`)
+}
+
+func TestUnmarshalJSONOnScalar(t *testing.T) {
+ type payload struct {
+ Name AccountName `json:"n"`
+ }
+
+ var p1 payload
+ err := json.Unmarshal([]byte(`{"n":"foo"}`), &p1)
+ AssertEqual(t, err, nil)
+ AssertEqual(t, p1.Name.Unpack(), "foo")
+
+ var p2 payload
+ err = json.Unmarshal([]byte(`{"n":"a+b"}`), &p2)
+ AssertErrorEqual(t, err, `value "a+b" is not acceptable for refined_test.AccountName`)
+
+ // This is the problematic case. If "n" is not mentioned in the JSON payload,
+ // UnmarshalJSON will never be called.
+ var p3 payload
+ err = json.Unmarshal([]byte(`{}`), &p3)
+ AssertEqual(t, err, nil)
+ AssertEqual(t, p3.Name.IsValid(), false)
+
+ // TODO: demonstrate basic use of ValidateUnmarshaled() here
+}
diff --git a/refined/shared.go b/refined/shared.go
new file mode 100644
index 0000000..67ee4d3
--- /dev/null
+++ b/refined/shared.go
@@ -0,0 +1,76 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package refined
+
+// A private type that appears in interfaces to make them unimplementable for types outside this package.
+type seal struct{}
+
+// This interface is currently only implemented by Scalar[S, V].
+// But by having this interface as an intermediate, New() and Literal() can
+// work with other classes of refinement types in the future.
+type isARefinementType[S any, V any] interface {
+ refinedSeal() seal
+ refinedNew(V) (S, error)
+}
+
+// This interface is currently only implemented by Scalar[S, V].
+// But by having this interface as an intermediate, ValidateUnmarshaled() can
+// work with other classes of refinement types in the future.
+//
+// Because of how the reflection logic in ValidateUnmarshaled() is written,
+// this interface is restricted to not have any type parameters.
+type validationTarget interface {
+ refinedSeal() seal
+ IsValid() bool
+}
+
+// New checks if the provided value is acceptable for the refinement type S,
+// and returns an instance of S if so, or an error if not.
+// For example, given a refinement type like:
+//
+// type AccountName struct {
+// refined.Scalar[AccountName, string]
+// }
+// // not shown: implementation of refined.IsAScalar[AccountName, string] interface on AccountName
+//
+// An instance of this refinement type can be constructed like so:
+//
+// var userInput string
+// accountName, err := refined.New[AccountName](userInput)
+func New[S isARefinementType[S, V], V any](value V) (S, error) {
+ var empty S
+ return empty.refinedNew(value)
+}
+
+// Literal is like New, but panics on error.
+// This function is intended for dealing with literal values, where unexpected errors are not possible.
+// For example:
+//
+// receiverName, err := refined.New[NonEmptyString]("Beitragsservice")
+// handle(err)
+// postCode, err := refined.New[PostCode](50616)
+// handle(err)
+// town, err := refined.New[NonEmptyString]("Cologne")
+// handle(err)
+// testAddress := Address {
+// ReceiverName: receiverName,
+// PostCode: postCode,
+// Town: town,
+// }
+//
+// can be shortened to:
+//
+// testAddress := Address {
+// ReceiverName: refined.Literal[NonEmptyString]("Beitragsservice"),
+// PostCode: refined.Literal[PostCode](50616),
+// Town: refined.Literal[NonEmptyString]("Cologne"),
+// }
+func Literal[S isARefinementType[S, V], V any](value V) S {
+ var empty S
+ s, err := empty.refinedNew(value)
+ if err != nil {
+ panic(err.Error())
+ }
+ return s
+}
diff --git a/refined/struct.go b/refined/struct.go
index 924c32e..65734b6 100644
--- a/refined/struct.go
+++ b/refined/struct.go
@@ -3,11 +3,13 @@
package refined
+/*
import . "github.com/majewsky/gg/option"
type Struct struct {
value Option[struct{}]
}
+*/
// TODO: require base struct type to have a deep-clone method
// TODO: func Get() that does a deep clone, func Set(), func Update(func(*BaseType))
diff --git a/refined/validate_unmarshaled.go b/refined/validate_unmarshaled.go
new file mode 100644
index 0000000..2a51bb6
--- /dev/null
+++ b/refined/validate_unmarshaled.go
@@ -0,0 +1,181 @@
+// SPDX-FileCopyrightText: 2025 Stefan Majewsky <majewsky@gmx.net>
+// SPDX-License-Identifier: Apache-2.0
+
+package refined
+
+import (
+ "fmt"
+ "reflect"
+ "strconv"
+ "strings"
+ "unsafe"
+
+ . "github.com/majewsky/gg/option"
+)
+
+// ValidateUnmarshaled calls IsValid() on each refinement type instance contained somewhere within the given data structure.
+// For each instance of a refinement type where IsValid() returns false, an error is returned.
+// Each returned error will be of type InvalidInstanceError.
+//
+// Invalid instances of refinement types can occur when unmarshaling data structures from JSON or similar formats.
+// See documentation on func Scalar.IsValid() for a detailed explanation.
+// After unmarshaling, the owner of the obtained data structure therefore needs to walk through it and check IsValid() on each instance of a refinement type.
+//
+// Because writing this check by hand is tedious, ValidateUnmarshaled() automates it.
+// Call this function after your Unmarshal() call.
+// For example with JSON:
+//
+// var payload SomeComplexStructWithNestedArraysMapsAndSubstructs
+// err := json.Unmarshal(buf, &payload)
+// handle(err)
+// errs := refined.ValidateUnmarshaled(payload, 1)
+// handle(errs)
+// // if there were no errors, it is now guaranteed that
+// // each refined.Scalar etc. contained within `payload` will be valid
+//
+// This function can handle inputs containing cyclic references (as can occur when unmarshaling YAML that contains anchor backreferences),
+// but it is not equipped to search in unexported struct fields.
+func ValidateUnmarshaled(input any) []error {
+ // As we traverse through the input, we need to keep track of where we are to report error locations correctly.
+ // But in the happy case of no errors, we want to do this tracking in a way that minimizes allocations.
+ // This is why we do all our tracking in this one path slice.
+ // When we recurse down, we add additional elements to the slice.
+ // And when we recurse back up, the previous function will still have the shorter slice and,
+ // by extending it again on the next call, overwrite the previous subpath.
+ path := make([]pathElement, 0, 16)
+
+ // We could have validateRecursively() return a list of errors, but then we would have to do
+ // useless extra allocations when building and then concatenating those lists of errors.
+ // It is cheaper to have a single error slice that everyone appends into.
+ var errs []error
+
+ // And as one final piece of setup, our cycle detection needs a place to remember visited objects in.
+ // We will only make entries here for heap-allocated objects that can actually participate in cycles.
+ // The design of this type is adapted from type visit used by reflect.DeepEqual().
+ vm := make(visitedMap)
+
+ validateRecursively(reflect.ValueOf(input), path, vm, &errs)
+ return errs
+}
+
+type pathElement struct {
+ // If this is set, this path element is an index into an array.
+ Index Option[int]
+ // If this is set, this path element is a field within a struct.
+ Field string
+ // Otherwise, this path element is a key within a map using a non-string type.
+ MapKey reflect.Value
+}
+
+type visitedKey struct {
+ Ptr unsafe.Pointer
+ Type reflect.Type
+}
+
+type visitedMap map[visitedKey]bool
+
+// IsVisited returns false exactly once for each value, and then true for each successive call.
+func (m visitedMap) IsVisited(v reflect.Value) bool {
+ k := visitedKey{v.UnsafePointer(), v.Type()}
+ if m[k] {
+ return true
+ }
+ m[k] = true // make next call to IsVisited return true instead
+ return false
+}
+
+// InvalidInstanceError is the error type returned by ValidateUnmarshaled().
+type InvalidInstanceError struct {
+ // A user-readable description of where the invalid instance was found within the input.
+ // Currently, this uses the JSON Pointer format of RFC 6901, but future versions may change this.
+ Path string
+ // The type of the invalid instance.
+ Type reflect.Type
+}
+
+func newInvalidInstanceError(path []pathElement, type_ reflect.Type) InvalidInstanceError {
+ // convert path into a JSON pointer [RFC6901]
+ tokens := make([]string, len(path)+1)
+ tokens[0] = "" // ensure leading slash
+ for idx, elem := range path {
+ var token string
+ switch {
+ case elem.Index.IsSome():
+ token = strconv.FormatInt(int64(elem.Index.UnwrapOr(0)), 10)
+ case elem.Field != "":
+ token = elem.Field
+ case elem.MapKey.Kind() == reflect.String:
+ token = elem.MapKey.String()
+ default:
+ token = fmt.Sprintf("%v", elem.MapKey)
+ }
+ token = strings.ReplaceAll(token, "~", "~0")
+ token = strings.ReplaceAll(token, "/", "~1")
+ tokens[idx+1] = token
+ }
+ pathStr := strings.Join(tokens, "/")
+ return InvalidInstanceError{pathStr, type_}
+}
+
+// Error implements the builtin/error interface.
+func (e InvalidInstanceError) Error() string {
+ return fmt.Sprintf("found an invalid %s at %s", e.Type.String(), e.Path)
+}
+
+func validateRecursively(v reflect.Value, path []pathElement, vm visitedMap, errs *[]error) {
+ // This switch writes out each kind specifically, because we want to have
+ // a loud error in the default case if later Go versions add new kinds.
+ switch v.Kind() {
+ case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, reflect.Complex64, reflect.Complex128, reflect.Chan, reflect.Func, reflect.String:
+ // All these types can absolutely not hold any refined values inside of them, so no work is necessary.
+
+ case reflect.Interface, reflect.Pointer:
+ if !v.IsNil() && !vm.IsVisited(v) {
+ validateRecursively(v.Elem(), path, vm, errs)
+ }
+
+ case reflect.Slice:
+ if v.IsNil() || vm.IsVisited(v) {
+ return
+ }
+ fallthrough
+ case reflect.Array:
+ for idx := range v.Len() {
+ subpath := append(path, pathElement{Index: Some(idx)})
+ validateRecursively(v.Index(idx), subpath, vm, errs)
+ }
+
+ case reflect.Map:
+ if !v.IsNil() && !vm.IsVisited(v) {
+ iter := v.MapRange()
+ for iter.Next() {
+ subpath := append(path, pathElement{MapKey: iter.Key()})
+ validateRecursively(iter.Value(), subpath, vm, errs)
+ }
+ }
+
+ case reflect.Struct:
+ if vt, ok := v.Interface().(validationTarget); ok {
+ if !vt.IsValid() {
+ *errs = append(*errs, newInvalidInstanceError(path, v.Type()))
+ }
+ } else {
+ for idx := range v.NumField() {
+ f := v.Field(idx)
+ // We cannot recurse into unexported fields; otherwise v.Interface() above will panic when recursing into another struct.
+ if f.CanInterface() {
+ subpath := append(path, pathElement{Field: v.Type().Field(idx).Name})
+ validateRecursively(f, subpath, vm, errs)
+ }
+ }
+ }
+
+ case reflect.UnsafePointer:
+ // For this type, we do not have any method of looking inside, so we cannot do anything.
+
+ case reflect.Invalid:
+ fallthrough
+ default:
+ panic(fmt.Sprintf("do not know how to handle value %#v of kind %s", v, v.Kind().String()))
+ }
+}