golang / go

The Go programming language
https://go.dev
BSD 3-Clause "New" or "Revised" License
122.8k stars 17.51k forks source link

proposal: spec: support for struct members in interface/constraint syntax #51259

Open davidmdm opened 2 years ago

davidmdm commented 2 years ago

Author background

Related proposals

yes

Proposal

The proposal is to extend the interface/constraint syntax to support struct members.

This proposal would allow for generic functions to be structurally generic. It would reduce a lot of boilerplate around getters and setters for simple struct members, and would/might/could increase performance for generic code by avoiding dynamic dispatch of interface methods for simple property accesses.

As a little bit of background, at the time of this proposal there is currently an issue being tracked for go1.19 about being able to use common struct members for generic types that are the union of structs . For example:

type Point2D struct { X, Y float64 }
type Point3D struct { X, Y, Z float64 }

func SomeOperation2D[T Point2D | Point3D](point T) {
   return point.X * point.Y
}

This fails like so:

point.X undefined (type T has no field or method X)
point.Y undefined (type T has no field or method Y)

Now the issue still stands as to whether or not Go should support the intersection of struct members for a union of struct types, but we can let that issue decide that use case.

The interesting thing that came out of the discussion, is that really what we want to express, is to be generic structurally hence:

func DoSomethingWithX[T struct { X float64 }](value T) float64 {
  return value.X
}

p2 := Point2D{}
p3 := Point3D{}

DoSomethingWIthX(p2) // would work becuase Point2D has member X
DoSomethingWithX(p3) // would work because Point3D has member X

However this does not seem to be compatible with what is being released in go1.18. Consider:

type A struct { X, Y int }
type B struct { X, C int }

func Foo[T A | B](value T) { ... }

We would no longer be able to express that we want exactly struct A or B, as this would express any superset struct of A or any superset struct of B.

Adding the ~ operator to signify we want the approximate shape of the struct seems like a likely candidate:

// Foo accepts any value that has struct member X of type int
func Foo[T ~struct { X int }](value T) { ... }

However this breaks the ~ operator as defined in go1.18 as to mean any type who's underlying type is what follows the tilde. I do not believe that making a special case for the tilde with a struct to be a good idea for orthogonality in the language.

Therefore, my proposal to is to extends our interface/constraint syntax to include struct members:

type Xer interface {
  X int
}

This works nicely with the idea of type sets and is fully backward compatible semantically with go1.18.

In the same way that type XGetter interface { GetX() int } represents the set of types that implement the method GetX() int, Xer would be the set of types that have a member X.

This way we don't need to touch what the tilde operator means, or how to interpret a type set of structs as constraint.

Slightly more illustrative example of what this might look like in real code:

type Point1D struct { X int }
type Point2D struct { X, Y int }
type Point3D struct { X, Y, Z int }

// type constraint
type TwoDimensional interface { X, Y int }

// Works for any struct with X and Y of type int, including Point2D and Point3D, etc, and excluding Point1D
func TwoDimensionOperation[T TwoDimensional](value T) { 
  return value.X * value.Y  // legal member accesses as described by the TwoDImensional constraint
 }

interace/constraint syntax allowing for struct members.

yes

I believe it to be orthagonal

Not necessarily but it may have performance benefits. Especially since currently, if we want to be able to pass any value with struct member X, we would need to create an interface with Getter and Setter methods; Generic struct member access is likely to prove to be faster than the current dynamic dispatch approach.

If so, what quantifiable improvement should we expect?

Readability and expressivity. Might make some generic code more performant.

Can't say.

Costs

I do not think it would make generics any more complicated than they already are. Indeed it might solve headaches for people running into this problem and make generics slightly easier to grasp or express overall.

Might cause confusion when reading struct{ X int } vs interface{ X int }, but I believe this to be minimal.

For all the prior questions: can't say. Defer to smarter people.

Merovius commented 11 months ago

Just want to put on record here that if we do this, we need to put in place the same restrictions we currently put on interfaces containing methods. That is, we should not allow interfaces containing field-constraints in union elements.

The proposal doesn't mention if it would be allowed for such fields to be type-parameter typed. i.e. is this legal?

type C[T any] interface {
    F T
}

Also, what about embedded fields? The obvious syntax for them would be ambiguous (interface{ F } is already a valid constraint, meaning something different). But it seems a bit strange to me, to have this limitation on the mechanism - to allow specifying that a type has a field, but not that it's an embedded field.

In general, I agree with @jub0bs that I dislike interfaces being about representation instead of behavior. But also, we already did that when we added generics, so I find it difficult to argue that this proposal would cross any additional lines in that regard.

Why methods belong to behaviors, but fields don't?

Because methods can be implemented regardless of representation. While fields are determined exclusively by representation.

jub0bs commented 11 months ago

@Merovius

I dislike interfaces being about representation instead of behavior. But also, we already did that when we added generics

Just to clarify: are you referring to type sets, e.g.

type Plusser interface {
    ~int | ~uint | ~string
}

I'm asking because I question claims that the motivation for type sets ever was to specify representation rather than behaviour. In my example above, the idea is to capture the + behaviour; that the only concrete types in that type set are ints, uints, etc. is only incidental.

davidmdm commented 11 months ago

@Merovius - my thoughts:

1.

we need to put in place the same restrictions we currently put on interfaces containing methods. That is, we should not allow interfaces containing field-constraints in union elements

Sounds good to me!

  1. is type C[T any] interface { F T } legal? I think so. In the same way that interfaces can express methods with type parameters, they should be able to express fields of type parameters.

  2. What about embedded fields?

When we express an interface: type MyInterface interface { X } we are embedding interfaces. I propose this syntax and semantics stay the same and not change.

Suppose the following types:

type Named interface { Name string }

type Person struct { Name string }

type Employee struct {
  Person
  Number int 
}

Here Employee embeds the Person type, and therefore has a promoted field Name of type string satisfying the interface Named.

TLDR: I don't think we need to be able to specify the set of types that embed a field. Just describe the fields that we need to have accessible via our type.

4.

I dislike interfaces being about representation instead of behavior.

I understand this, and pre-generics it made 100% sense. We needed to have a mechanism for polymorphism that abstracted way the internal representation of the object. This is why, I believe, that in one of the generic proposals we had the two distinct concepts: interfaces and constraints. However constraints were folded back into interfaces with the idea that interfaces are no longer a description of a set of behaviours but instead the representation of a set of types.

With that in mind my proposal shouldn't be more offensive than the changes that have already been made to interfaces like interface { int8 | int16 | int 32 | int64 | int }. Since we want to be able to constrain type parameters using interfaces that describe a set of types, we should be able to constrain them by their representation as well as by their behaviours.

Merovius commented 11 months ago

TLDR: I don't think we need to be able to specify the set of types that embed a field. Just describe the fields that we need to have accessible via our type.

I don't believe that really resolves my concern. An embedded field does more than just promote its fields. It also promotes methods. And it makes the embedded type available as a selector expression. In your example, if e is an Employee, then e.Person is a Person. That is not a property captured by having a field named Name. And then there is this example:

type A struct {
    X int
}
type B struct {
    Y int
}
type C struct {
    X int
    Y int
}
type D struct {
    A
    B
}
type E struct {
    C
}

D andE are quite different, semantically, in a way that isn't really captured by saying "both have fields X and Y".

I think it is a valid answer that you couldn't express that constraint. But it still seems like a limitation to me, that is a strange corner.

Since we want to be able to constrain type parameters using interfaces that describe a set of types, we should be able to constrain them by their representation as well as by their behaviours.

FWIW I don't agree with this inference. I find it hard to argue that we should not include aspects of representation into constraints. But I don't think it is at all self-evident that we should do more of that. And it could prove a rather deep rabbit-hole, to explore all the aspects of representation you might be interested in.

davidmdm commented 11 months ago

I am sorry, but it is unclear to me what the objection is. It feels to me that a lot of this is quite nit-picky, and perhaps a knee-jerk reaction to what would be a big change in interfaces which are very core to Go, and I would not want to rush this decision either.

However, it seems clear to me that the idea of constraining types by data is not a novel idea. It is very analogous to interfaces that describe Getter/Setter functions, except that types wouldn't need to explicitly define those methods just to trivially return their data.

FWIW I don't agree with this inference.

Can you explain why you think we should NOT be able to constrain them (types) by their representation (Fields/Data) as well as by their behaviours?

Merovius commented 11 months ago

@jub0bs I do agree with you about the intent, but the mechanism is still a constraint on representation. There is still no possible argument that interface{ ~int } is anything but a constraint about how a type is represented, in my opinion.

@davidmdm I chose the term "representation" instead of "data" because it is more accurate to what I'm talking about. An interface having "getters and setters" is relatively accurately described as dealing with "data", but not as "representation".

If you define an interface GetFoo() int (a getter), I can have a func() type with a GetFoo() int method implement that interface. The interface is completely independent from the representation. This interface might express the data your function needs to work, but it doesn't say anything about how that data is represented. This proposal expresses a constraint over representation. I can not implement interface{ Foo int } using a function (or some other) type. Personally, I find that a pretty restrictive constraint and a qualitative change in the roles interfaces have had in the past.

FWIW I've so far avoided really taking a strong position on this proposal, because I don't really have one. I'm leaning towards rejecting it, because I don't like to expand the role of interfaces into constraining representation. But it's not a particularly strong argument, because as of right now, we have some of that already. But I'm also not super happy about that, so I disagree that "we can do some of X, so we should do more of X" is sound reasoning.

I've just tried hammering out the details, to make the implications of this proposal clear. If we want to do this, we should be aware on the restrictions on union-elements, the limitations about embedded fields, or the use of type parameters. I asked about the first, because I wanted to ensure we don't accidentally decide to do something we can't implement. I asked about the latter two, because I remember that there are some limitations about using type parameters - for example, you can't have embedded fields be type-parameters, you can't have generic type-aliases because of some open questions on recursive types and… perhaps others? I'm not sure.

And yes, to me, the limitation that you can't express a constraint on an embedded field is a definite limitation of this proposal. It is just as natural a constraint to express as a constraint to have a non-embedded field. It's possible to do this proposal even with that limitation - but we should acknowledge it exists.

I just felt it would be useful to have these questions answered. I wasn't trying to pick nits.

davidmdm commented 11 months ago

@Merovius

Thanks for that explanation, and I want to apologize if I was accusatory of any ill intention (nit-picking or otherwise). I suppose the conversation was going in directions that I failed to see as important, and it was starting to frustrate me. I apologize.

If I may can I ask you to give your thoughts on the transition to the idea of interfaces as sets of types.

To me fundamentally there seems to be a tension between Go pre and post generics around this topic.

There's no doubt that using interfaces to describe behaviour gives types free reign to implement it regardless of its representation. An interface of methods is a higher and more powerful abstraction than an interface of structure or representation.

However since Go 1.18 and the advent of generics, we've allowed interfaces to represent sets of types, and type unions are a direct consequence of that.

Indeed we can express interfaces to be very restrictive as to the type or structure by making a type set of only 1 type: type Int interface { int }.

It feels to me that being able to create constraints around the structure of types (fields or access to fields) is the missing piece that fits in between enumerating types via type unions, and the set of types that implement a set of behaviours.

My view is that Go is like the ugly duckling stuck in the middle of an odd transition: interfaces represent a set of behaviour / interfaces represent a set of types.

Today it is hard to make interfaces represent the set of types we may want. Indeed we need to modify the types to implement the interfaces and it makes it harder to work with third parties.

This being said, I agree there may many limitations to the approach I have outlined in the proposal. I would be fine with the same limitations that method sets have with type unions today. I would be fine if they could only be used as type constraints and not as interface values in the same way that type unions cannot be used as values in programs today.

Do we commit to interfaces as type sets or no? Without an answer to this question I do not think we should reject this proposal even if it hangs around a long time.

Merovius commented 11 months ago

I don't think that the salient decision is whether or not interfaces describe "sets of types" or not. That's largely a philosophical question - pre-Go-1.18-interfaces can just as much viewed as sets of types, as post-Go-1.18-interfaces can. The distinction is what kinds of sets of types we can describe. Pre Go 1.18, the sets we could describe where all phrased as "all types that included these methods in their method set". Post Go 1.18, we expanded that description language to include "this concrete type", "all types with this underlying type" and intersections and unions (with some limitations) of these sets. This proposal is to expand that description language to include "sets of struct types with a field F of type T". There are many other possible extensions to the description language of type sets we can envision.

But it will never be possible to describe all sets of types (there are complicated reasons why that would be infeasible, if not impossible, to implement). For example, "the set of all int-arrays with a prime length" is a set of types that we will never be able to describe. Ultimately, we will have to make concrete decisions about each and every extension to that constraint language. And such concrete decisions will have to be based on concrete criteria, like 1. how useful is this extension and 2. what are its costs.

I don't really agree with you, that this proposal is "the (sic) missing piece" in the constraint language. It is certainly something we can't do right now. It's certainly something reasonable to want to do. But I'm not convinced that it is a particularly important missing piece, personally. For example, it would seem far more useful and powerful to me, to have variadic type parameters or some other facility to express concepts like "a function taking an arbitrary number of inputs and an arbitrary number of outputs, the last of which is an error". Which would be useful to write generic error-handlers. Or would simplify the iterators discussion immensely. The inability to manipulate function types this way seems a far more significant hindrance to the kind of generic code we can write today - at least from my limited perspective.

But making that decision requires 1. use cases and 2. the kinds of detailed questions I tried to get into above. Ultimately, those are the important questions that most proposals will come down to: What kinds of code does it enable us to write and is that justifying its cost? Evaluating the cost requires us to know what is proposed exactly.

Without an answer to this question I do not think we should reject this proposal even if it hangs around a long time.

Note that I did not suggest to reject this proposal. [well, that is untrue, I did say I lean towards rejecting it. Though I also said it's not a strong opinion] From what I can tell, the discussion is on-going. To bring us out of the realm of philosophizing and back to the specifics:

  1. Ian has said he is unsure about how often this issue comes up. There are some cases mentioned in #48522. I think it would be interesting or helpful to try to quantify these (though I don't know how).
  2. Ian has also said that unless we can find an answer to how this proposal would work without the distinction between constraints and interfaces, it won't be accepted (for now).

Personally, I strongly agree that if we do this proposal, it should be allowed to use these interfaces as normal types. I've run into the FileInfo use case mentioned here myself just a few days ago. And also happened upon it when trying to avoid boilerplate with the subcommands package. Personally, I have more use-cases to use the proposed interfaces as normal types, than with generic code.

So, I'd recommend focusing on these concrete questions.

mitar commented 10 months ago

I have another example I would like to have supported. I originally posted it in #48522 but was directed here. This issue seems to have complications of how multiple constraints combine together, but in my use case I really care only about accessing to the embedded struct:

type Base struct {
    Name string
}

type Extended struct {
    Base
    Age int
}

func SayHi[T Base](x T) (T, string) {
    return x, fmt.Sprintf("Hi, %s!", x.Name)
}

func main() {
    x, hi := SayHi(Extended{Name: "foo", Age: 30})
    fmt.Println(hi)
    fmt.Println(x.Age)
}

The example is a bit contrived, but the idea is that SayHi should be able to access everything on embedded Base (both fields and methods), but the returned x is in fact of the type passed in. So the extra fields and methods are available and can be passed through, while my code just cares about the Base.

Merovius commented 10 months ago

@mitar Personally, I'm not a fan. IMO the easiest way to write that function would be to have SayHi just take a *Base. But even ignoring that - currently Base refers to exactly the type Base¹, not anything embedding it. You can't pass an Extended to a func (b Base), for example. I do not like the idea of having it mean "or something embedding Base" in some contexts.

But note that I mentioned above, that we should - in my opinion - also consider how to express the constraint that a field is embedded, if we do accept this proposal. That would subsume what you want, because you could then write func SayHi[T Base|HoweverWeExpressEmbeddingBase]. And it would make the embedding relationship explicit, avoiding my criticism.

[1] And I'd also note that the choice of type-names suggests that you are trying to do OOP-hierarchies and inheritance. If we think that is a good idea, we should introduce inheritance, not inheritance-but-worse. In my opinion.

mitar commented 10 months ago

IMO the easiest way to write that function would be to have SayHi just take a *Base.

But then it cannot return also the original Extended value as well.

And I'd also note that the choice of type-names suggests that you are trying to do OOP-hierarchies and inheritance.

No, I am trying to support base functionality where one can extend it for their needs but my code just cares about base functionality. So I do not need that extended struct overrides/shadows base fields/methods. Just that that extended data is passed through.

Currently I am using reflect to achieve this:

// FindInStruct returns a pointer to the field of type T found in struct value.
// value can be of type T by itself and in that case it is simply returned.
func FindInStruct[T any](value interface{}) (*T, errors.E) {
    // TODO: Replace with reflect.TypeFor.
    typeToGet := reflect.TypeOf((*T)(nil)).Elem()
    val := reflect.ValueOf(value).Elem()
    typ := val.Type()
    if typ == typeToGet {
        return val.Addr().Interface().(*T), nil
    }
    fields := reflect.VisibleFields(typ)
    for _, field := range fields {
        if field.Type == typeToGet {
            return val.FieldByIndex(field.Index).Addr().Interface().(*T), nil
        }
    }

    errE := errors.WithDetails(
        ErrNotFoundInStruct,
        "getType", fmt.Sprintf("%T", *reflect.ValueOf(new(T)).Interface().(*T)),
        "valueType", fmt.Sprintf("%T", value),
    )
    return nil, errE
}

This allows me to search for Base in Extended, or somebody can just pass Base in. And then I can access fields and methods on it. The issue is that there is no type checking.

Merovius commented 10 months ago

But then it cannot return also the original Extended value as well.

It doesn't need to. It takes a pointer.

davidmdm commented 10 months ago

@mitar

I think that the under the spirit of this proposal, the example would work slightly differently. What we would want to express here is that SayHi expects a type that has access to a Name field of type string.

IE:


type Named interface {
  Name string
}

// SayHi is constrained by the Named interface which represents
// the set of types that have access to a field Name of type string
func SayHi[T Named](x T) (T, string) {
    return x, fmt.Sprintf("Hi, %s!", x.Name)
}

type Base struct {
    Name string
}

type Extended struct {
    Base
    Age int
}

func main() {
        // Here SayHi is instantiated as SayHi[Extended], 
        // since Extended satisfies the interface "Named" via its promoted field from Base.
    x, hi := SayHi(Extended{Name: "foo", Age: 30})
    fmt.Println(hi)
    fmt.Println(x.Age)
}

@Merovius

Under this proposal at least, I don't think that specifying "embeded-ness" of a field to be vital. If this were to pass it could be a separate proposal.

Although I agree with you about this example having its roots in OOP and that I do not recommend thinking in those terms when it comes to Go. If we wanted to follow out the thinking of the example but with a Go flavour, we would think about the set of types that have a Base instead of the set of types that are (or extend) a Base.

In that case, following this proposal, you could write:

type Based interface {
  Base Base
}

Of which any type that has access to a Base field of type Base would satisfy the interface. This includes both the set of types that have a field explicitly called Base of type Base: struct { Base Base } and types that embed base: struct { Base }. In both cases the Base field is accessible as x.Base.

In my opinion expressing the set of types that embed Base should not block this proposal in and of itself, and if that indeed is a desire that comes up frequently enough, it should be revisited on its own.

edit - typos

Merovius commented 10 months ago

@davidmdm To be clear, my main concern was to have an answer of how to handle embedded fields. "Writing X X allows structs with embedded fields of type X to satisfy the constraint" is such an answer (you didn't say you intended that to be possible above, as far as I remember).

I'm not sure what I think about that answer, but it's something to think about.

Merovius commented 10 months ago

One thing I'd point out is that not every embedded field has the same name as the type. For example

type A struct { X int }
type B = A
type C struct { B }

C embeds a field of type A, but it is named B. I think that in this case, the constraint would have to be spelled interface{ B A } - which notably wouldn't be satisfied by struct{ A }. That's probably still the right semantic under this proposal, but it's a subtle semantic difference to what @mitar requested.

mitar commented 10 months ago

I found another approach:

type Base struct {
    Name string
}

func (b *Base) GetBase() *Base {
    return b
}

type hasBase interface {
    GetBase() *Base
}

type Extended struct {
    Base
    Age int
}

func SayHi[T hasBase](x T) (T, string) {
    return x, fmt.Sprintf("Hi, %s!", x.GetBase().Name)
}

func main() {
    x, hi := SayHi(&Extended{Base: Base{Name: "foo"}, Age: 30})
    fmt.Println(hi)
    fmt.Println(x.Age)
}

So it seems I just want a way to not have to define GetBase and hasBase but be able to directly access Base.

zigo101 commented 10 months ago

I agree with @davidmdm's opinion. Personally, I even think "embedding field" should never play a role in constraints. Field sets in constraints should always denote behaviors, no others.

Nitpick to @davidmdm's code: Extended{Name: "foo", Age: 30} should be written as Extended{Base: Base{Name: "foo"}, Age: 30}.

davidmdm commented 10 months ago

@go101 thanks. I did some copy-pasta!

doggedOwl commented 10 months ago

I am very confused by this "must be behaviour" discussion because that ship sailed when interfaces were extended to denote also constrains. A constrain of type int | float does not specify a behaviour but a set of types. with some mental gymnastics you can try to interpret this as types that satisfy behaviour + but that is simply false because that behviour is clearly more spread than just to int | float. if we were defining that behaviour than strings would be acceptable too.

Type Parametrization is clearly about types and not about behaviour.

By definition: "an operation is permitted if it is permitted for all types in the type set defined by the constraint". Clearly if all types of the set permitt the operation "access to field X" this is within that definition.

Now I also undertand that finding a generic way to express all possible definitions of a type is hard and maybe even not feasable but let's not waste time discussing about decisions that are already in place.

gophun commented 10 months ago

with some mental gymnastics you can try to interpret this as types that satisfy behaviour + but that is simply false because that behviour is clearly more spread than just to int | float. if we were defining that behaviour than strings would be acceptable too.

Operators were the sole reason this syntax was introduced at all. Anybody using A | B for some other purpose than capturing an operator is basically abusing it.

Merovius commented 10 months ago

@gophun I don't believe that is true. For one, it is a necessary feature to allow specifying that a method must be on a pointer type, which has little to do with operators. More importantly, an important thought behind union elements was also #57644. And if we really where only concerned with operators, we would have only allowed ~T elements in unions. There's no reason to even have something like float|int if it's just about operators.

Really, unions just say what they say: A type argument must be one of these listed types. And it's a reasonable constraint. No abuse here.

Merovius commented 10 months ago

@doggedOwl

By definition: "an operation is permitted if it is permitted for all types in the type set defined by the constraint". Clearly if all types of the set permitt the operation "access to field X" this is within that definition.

That is not what this issue is about. This issue is about introducing a new kind of constraint. Allowing field access on already expressible constraints is #48522 (TBQH it's a bit strange to me, that these two issues constantly get mixed up. ISTM their titles are sufficiently clear in their respective intent).

doggedOwl commented 10 months ago

@Merovius yes I know that is another issue but in my confusion that the merits of this proposal are being discussed on some false premises I also failed to express that I don't support this proposal because it's introducing a constrain that is already inherit in the type definition itself if rules were relaxed (in accordance with the definition) so that #48522 was permitted. (at least when you know the list of types in question, the more generic case that this would enable is in my opinion more complexity for little added benefit over the #48522)

@gophun access to operators is one of aspects but the also one of the main benefits, at least for me, is to use compile time type safety on many places where interface{} was used before. In that context, the constrain of type unions like type1 | type2 etc are very important and again have nothing to do with behaviour. This use cases are at the moment being hindered or at least not ergonomic enough because of some restrictions that were put in place to get the initial version out faster but that can be relaxed because they are still within the initial vision of what type parameters would enable in Go.

gophun commented 10 months ago

@gophun access to operators is one of aspects but the also one of the main benefits, at least for me, is to use compile time type safety on many places where interface{} was used before. In that context, the constrain of type unions like type1 | type2 etc are very important and again have nothing to do with behaviour.

It's ok to want type unions, but don't abuse generics for it. Type unions belong first and foremost on the non-generic side of the type realm, which would be #57644.

DeanPDX commented 5 hours ago

I run into situations where I would like this functionality from time to time. Today I was working on a system with quite a few reports that are mostly rendered as charts on a dashboard. So I have 9 charts, and each has its' own type. But each of them has similar functionality in that they have a concept of TargetMonth. I don't know if I will have data for each month in the database (I'm bulk-loading it from a system that is outside my control) but I always want to display a single fiscal year in each chart. I also have a "target" that I want to include in my padded rows as it doesn't change over time.

So what I want to do is create a function like this:

type ReportRow interface {
    FiscalMonth     time.Time
    Target      int
}

// Ensure we have a row for each month in the fiscal year for any
// report row that has `FiscalMonth` property.
func EnsureFiscalYear[T ReportRow](rows []T) []T {
    fiscalStart := currentFiscalStart()
    dest := make([]T, 12)
    for i := range dest {
        currentMonth := fiscalStart.AddDate(0, i, 0)
        found := false
        // If we have an existing row in our collection, use that
        for _, v := range rows {
            if v.FiscalMonth == currentMonth {
                dest[i] = v
                found = true
                break
            }
        }
        // If we didn't find a row for this fiscal month, pad with zero value
        if !found {
            var row T
            row.FiscalMonth = currentMonth
            // If we have any rows, we can set the target to a meaningful value on our zero value row
            if len(rows) > 0 {
                row.Target = rows[0].Target
            }
            dest[i] = row
        }
    }
    return dest
}

My Current Solution

I landed on doing something like this:

type ReportRow[T any] interface {
    GetFiscalMonth() time.Time
    WithBaseData(original T, fiscalMonth time.Time) T
}

// Possibly the ugliest function signature of all time. Forgive me.
func EnsureFiscalYear[T ReportRow[T]](rows []T) []T {
    // If we have 0 rows nothing we can really do.
    if (len(rows) == 0) {
        return rows
    }
    fiscalStart := currentFiscalStart()
    dest := make([]T, 12)
    for i := range dest {
        currentMonth := fiscalStart.AddDate(0, i, 0)
        found := false
        // If we have an existing row in our collection, use that
        for _, v := range rows {
            if v.GetFiscalMonth() == currentMonth {
                dest[i] = v
                found = true
                break
            }
        }
        // If we didn't find a row for this fiscal month, pad with zero value
        if !found {
            var row T
            dest[i] = row.WithBaseData(rows[0], currentMonth)
        }
    }
    return dest
}

type ReportARow struct {
    FiscalMonth time.Time
    Target      int
    // ...
}

// And then for each report type I have to
// copy/paste to implement the interface:
func (r ReportARow) GetFiscalMonth() time.Time {
    return r.FiscalMonth
}
func (r ReportARow) WithBaseData(original ReportARow, fiscalMonth time.Time) ReportARow {
    r.FiscalMonth = fiscalMonth
    r.Target = original.Target
    return r
}
// repeat for every report type...

I think my case is odd because not only do I want to do the same thing to similar-looking data (in that case, use an interface), I also want to mutate the data based on a shared set of properties. I'm actually pretty happy with the implementation and wanted to share with anybody who is looking for solutions here. It's a little copy/pasty to implement the interface, but, I was able to keep my EnsureFiscalYear logic to a single function which was my main goal.

Anyway, I do think there is value in being able to constrain items to types that have a shared set of properties and wanted to add a real-world scenario here.