Structural constraints

So far all constraints have referred to primitive types or interfaces expressing a composite set of defined types or type definitions with the same underlying type of defined types. However, generics in Go also supports structural constraints. Imagine the following use case:

  • we need a function that accepts any struct that has fields for:
    • ID
    • numeric values
    • sum function

In other words what is needed is a function that takes an instance of Ledger[T, K] (Go playground):

  1. func SomeFunc[T ~string, K Numeric](l Ledger[T, K]) {}
  2. func main() {
  3. SomeFunc[string, int](Ledger[string, int]{
  4. ID: "acct-1",
  5. Amounts: []int{1, 2, 3},
  6. SumFn: Sum[int],
  7. })
  8. }

:warning: Eagle-eyed readers may notice that SomeFunc does not actually do anything. That is because there is currently a bug in Go 1.18beta2 that should be resolved by the time 1.18 is released. For now, the issue golang/go#50417 prevents reading and writing fields with structural constraints.


The program compiles and runs fine, except the use case was not for a function that takes an instance of Ledger[T, K] but for a function that matches any struct with those fields. That sounds a lot like the tilde ~ operator… (Go playground):

  1. func SomeFunc[T ~string, K Numeric, L ~Ledger[T, K]](l L) {}
  2. func main() {
  3. SomeFunc[string, int, Ledger[string, int]](Ledger[string, int]{
  4. ID: "acct-1",
  5. Amounts: []int{1, 2, 3},
  6. SumFn: Sum[int],
  7. })
  8. }

But of course this does not compile:

  1. ./prog.go:47:39: invalid use of ~ (underlying type of Ledger[T, K] is struct{ID T; Amounts []K; SumFn SumFn[K]})
  2. ./prog.go:50:24: cannot implement ~Ledger[string, int] (empty type set)

The secret here is all structs implement the anonymous struct, so if we want to match all structs with the aforementioned fields, we want to use ~ with an anonymous struct (Go playground):

  1. func SomeFunc[
  2. T ~string,
  3. K Numeric,
  4. L ~struct {
  5. ID T
  6. Amounts []K
  7. SumFn SumFn[K]
  8. },
  9. ](l L) {
  10. }
  11. func main() {
  12. SomeFunc[string, int, Ledger[string, int]](Ledger[string, int]{
  13. ID: "acct-1",
  14. Amounts: []int{1, 2, 3},
  15. SumFn: Sum[int],
  16. })
  17. }

In fact, the above example even be rewritten so the call to SomeFunc relies on type inference (Go playground):

  1. func main() {
  2. SomeFunc(Ledger[string, int]{
  3. ID: "acct-1",
  4. Amounts: []int{1, 2, 3},
  5. SumFn: Sum[int],
  6. })
  7. }

Now that SomeFunc can accept any struct that matches the constraint, it’s possible to send other types as well (Go playground):

  1. type ID string
  2. type CustomLedger struct {
  3. ID ID
  4. Amounts []uint64
  5. SumFn SumFn[uint64]
  6. }
  7. func main() {
  8. // Call SomeFunc with an anonymous struct that uses a type
  9. // alias for "string" as the type for the "ID" field.
  10. //
  11. // Please note that because a type alias is used in a nested
  12. // type, inference does not work.
  13. SomeFunc[ID, float32, struct {
  14. ID ID
  15. Amounts []float32
  16. SumFn SumFn[float32]
  17. }](struct {
  18. ID ID
  19. Amounts []float32
  20. SumFn SumFn[float32]
  21. }{
  22. ID: ID("fake"),
  23. Amounts: []float32{1, 2, 3},
  24. SumFn: Sum[float32],
  25. })
  26. // Compare that to this call which *also* uses an anonymous
  27. // struct, but gets to rely on type inference. This is because
  28. // the "ID" field is a "string" and can be infered from the
  29. // nested type.
  30. SomeFunc(struct {
  31. ID string
  32. Amounts []float32
  33. SumFn SumFn[float32]
  34. }{
  35. ID: "fake",
  36. Amounts: []float32{1, 2, 3},
  37. SumFn: Sum[float32],
  38. })
  39. // Call SomeFunc a Ledger[T, K].
  40. SomeFunc(Ledger[string, complex64]{
  41. ID: "fake",
  42. Amounts: []complex64{1, 2, 3},
  43. SumFn: Sum[complex64],
  44. })
  45. // SomeFunc can also be used with other concrete struct types
  46. // as long as they satisfy the constraint.
  47. SomeFunc[ID, uint64, CustomLedger](CustomLedger{
  48. ID: ID("fake"),
  49. Amounts: []uint64{1, 2, 3},
  50. SumFn: Sum[uint64],
  51. })
  52. }

However, please note the following will not work (Go playground):

  1. type LedgerNode struct {
  2. ID string
  3. Amounts []uint64
  4. SumFn SumFn[uint64]
  5. Next *LedgerNode
  6. }
  7. func main() {
  8. // This will fail because LedgerNode does not match the constraint
  9. // exactly. The presence of the additional field will result in a
  10. // compiler error.
  11. SomeFunc(LedgerNode{
  12. ID: "fake",
  13. Amounts: []uint64{1, 2, 3},
  14. SumFn: Sum[uint64],
  15. })
  16. }

Instead the above fails to compile with the following error:

  1. ./prog.go:69:10: L does not match struct{ID T; Amounts []K; SumFn SumFn[K]}

Structural constraints must match the struct exactly, and this means even if all of the fields in the constraint are present, the presence of additional fields in the provided value means the type does not satisfy the constraint.

So how does one express the presence of additional information? With an interface of course!


Next: Interface constraints