Replies: 3 comments 4 replies
-
hello @abhinav, I would like to express my admiration for this RFC, not only for the proposed approach, but also for how thoughtfully and clearly its has been written. It is a pleasure to read. This will be a great improvement indeed. Keep up the good work! Kudos! |
Beta Was this translation helpful? Give feedback.
-
@abhinav I think we should include unions in this design to bring Go up to the same safety and usability of other supported languages. The most obvious approach would be to replicate the union types from dotnet. These have the downside of being slightly clunky to work with but would be completely safe. If there's plans to support discriminated unions within Go then perhaps we should consider waiting for that. My expectations would be that either of these approaches would be a breaking change to introduce from the current untyped approach because they would require a user to change their program's code. I don't think there'd be an easy way to make it backward compatible. We should therefore consider including this as part of the Generics rollout because introducing a later breaking change will be quite difficult to roll-out. |
Beta Was this translation helpful? Give feedback.
-
Hello all, the initial implementation of our Go Generics support has landed in v3.80.0. See the release notes in #13855 |
Beta Was this translation helpful? Give feedback.
-
Hey folks!
We've been exploring what it would take to introduce generics to the Pulumi Go SDK. We have a direction that we're reasonably happy with, so we'd like to post it here publicly to gather feedback as we begin implementing it.
Please have a look at the document below and feel free to share your thoughts with us.
Thanks!
Simplifying the Pulumi Go SDK with generics
Issue: #9143
Abstract
This document proposes an alternative API for the Pulumi Go SDK that leverages type parameterization (generics) to reduce complexity and improve ergonomics of the SDK.
It proposes a way to achieve this API in a backwards-compatible manner with a gradual off-ramp from the current APIs for when we're ready for a major version bump.
Background
The Pulumi Go SDK was designed before Go had support for generics. The SDK offers the best experience it could given the constraints of the language at the time.
The language has since evolved and added support for generic types and functions. This unlocks a host of improvements that we could make to the Go SDK for Pulumi.
Prior work
In a prior Hackathon, @AaronFriel put together a prototype incorporating generics into Pulumi's Go SDK. He demonstrated how a completely new API could replace the Input and Output types. This API is summarized in Appendix: Hackathon Prototype.
Motivation
This section talks about a few problems with the Pulumi Go SDK today that we use as motivation for choices in the new design.
Unsafe Inputs, Outputs, and Apply
This is perhaps the most common complaint about the Pulumi Go SDK. The
Input
andOutput
types, and theapply
operation all rely heavily oninterface{}
. This makes them effectively untyped at compile-time, instead relying on type matching at runtime.For instance, the
apply
operation is implemented in Go as the following method:The contract for ApplyT states that
applier
must be a function with one of the following signatures:Where
U
must be assignable from the type contained in the underlyingOutput
. If it isn't, the operation will panic.The caller will usually want to cast the result into a specific output type. If they choose the wrong output type, this operation will panic.
So given a typical usage like the following, there are two spots that can panic: the
ApplyT
invocation and theIntOutput
cast.Excessive code generation
The Go SDK relies heavily on code generation in an attempt to alleviate the pain of untyped operations. It generates type-specific input and output wrapper types for nearly every type it encounters.
In the core Pulumi SDK, this includes all primitive types, their pointers, containers, and containers of containers. For instance, the core SDK has 22
String*
types besidestype String
itself, covering nearly all variations of the following pattern:This pattern of code generation carries over to code generated from schemas, though not to this degree of nesting. Every type gets an input and output type, a pointer variant if the type is referenced as a pointer, and container variants if the type is referenced from a container.
In general, this generates extremely large packages that are ungainly to navigate—both in source form and in their API reference. This can even lead to code generation timeouts.
Redundant container types
Related to the previous issue is the fact that the Go SDK generates container wrapper types at all. These exacerbate the difficulty in navigating the code or documentation for a generated package. As of writing this, 85% of the output types in the Pulumi core SDK, and over half the output types in the AWS S3 package, are container or pointer types.
The following table contains rough counts of output types in different Pulumi packages and the portion of them that are array, map, or pointer types.
Design
Based on the motivations above, the new design has two high-level goals:
The design also has the following logistical requirements:
Concepts
We will put aside the backwards-compatibility requirement temporarily and discuss how the core Pulumi concepts (Input, Output, and Apply) could be made type-safe. Backwards compatibility is discussed below separately.
Output
Output is defined as a generic type that embeds OutputState. This operates like any other custom output type today.
Input
Input is a generic interface that may be used as a type constraint. Similar to how generated input types operate today, an input for a type is any value that can convert itself into an output of that type.
The interface additionally embeds the existing
pulumi.Input
interface for backwards compatibility. See Conceptual Compatibility for details.Note that existing generated
FooInput
types have bothToFooOutput
andToFooOutputWithContext
methods. We're opting for a single method that always expects a context to encourage having a context passed in more often. This API is not intended for use by end users (although they are allowed to), so we don't need to optimize its ergonomics for the zero arguments case.Output[T]
implements this interface:Apply
With the types above, it's possible to implement a type-safe
Apply
function with the following signature:Given a value that can become an output, and a function that consumes its result, return an output holding that function's result.
We will also include variations of
Apply
with context and error support.Join
The existing
Output.ApplyT
method handles Outputs of outputs. We cannot express this cleanly with a type-safeApply
function.To facilitate this unwrapping, we'll introduce a
Join
function:Note:
O
is the first type parameter. See Why are Join's type parameters out of order? for details.Backwards compatibility
There are two aspects to backwards compatibility for the new design:
We'll talk about conceptual compatibility first since that further informs the design.
Conceptual compatibility
The Concepts section provided new definitions of Input, Output, and Apply. We need these to be compatible with existing definitions of those same concepts. As a refresher, current definitions (in Pulumi v3) are summarized in Appendix: Concepts in v3.
To avoid ambiguity, this section will refer to current definitions with the namespace
pu
and the new generic APIs with the namespacepux
. We're trying to introduce the following replacements:pu.Input
pux.Input[T]
pu.Output
pux.Output[T]
pu.Output.ApplyT
pux.Apply
For conceptual compatibility, we have the following needs
pu.Output
as apux.Input[T]
pu.Output
as apux.Output[T]
pux.Output[T]
as a specificpu.Input
extensionpux.Output[T]
as a specificpu.Output
implementationUpgrading
Upgrading to
pux.Input
To support upgrading a
pu.Output
type into apux.Input[T]
, we'll have all generated input and output types implement theToOutput
method to satisfypux.Input[T]
. For example:This is enough to turn all old-style output implementations into valid, strongly-typed
pux.Input[T]
. More importantly, this makes all these implementations into valid inputs topux.Apply
with signatures and types checked at compile time!Being able to upgrade like this makes it nearly free for users to begin using the type-safe APIs.
Upgrading to
pux.Output
Being able to upgrade a
pu.Output
topux.Input[T]
should suffice for most cases, but when a user specifically needs apux.Output[T]
, they'll be able to useConvertTyped
orMustConvertTyped
:For example:
These functions are only useful when the user has a
pu.Output
orpu.AnyOutput
. If they have a specific output implementation, they can also just use theToOutput
method.Downgrading
Downgrading to
pu.Input
We cannot support transparently passing a
pux.Output[T]
into a specificpu.Input
because these usually take the form:It is impossible for any generic output type to implement every possible
To[Name]Output
method, because the set of output types isn't closed: types are generated separately for each provider SDK.Instead, we'll rely on downgrading to the corresponding output type.
See next section.
Downgrading to
pu.Output
It is possible to dynamically convert a
pux.Output[T]
into the corresponding output type at runtime because all output types are registered with the SDK.We will provide an
Untyped()
method onpux.Output
that returns thepu.Output
.This will be used like so:
This value will be usable as both, a
pu.Input
and apu.Output
.Note: A safer way to do this conversion is through the use of
pux.Cast
:Library compatibility
The definitions above used the names
Input
andOutput
. These are already taken in the Pulumi Go SDK by the non-generic input and output type interfaces.To introduce the new APIs in a backwards compatible way, we have two options:
In
andOut
) and put everything in the same package. This has the benefit of making the implementation easier:pux.Output
andpu.Output
can access each other's internal state.We recommend putting the new definitions in a 'pulumix' sub-package if possible.
This will require, at minimum, moving the following from the 'pulumi' package into an internal package and re-exporting them:
Additionally, the
pux.Input
andpux.Output
types, and some of their core functionality will also reside in the same internal package, re-exported from the 'pulumix' package.This will have some negative effect on maintainers since there's an extra hoop to jump through to avoid the cyclic imports in exchange for a cleaner, more final user-facing API.
Literals
It's not uncommon to need to build output values, or pointer output values, from a literal. We will include
Val
andPtr
helper functions to aid in this:Container literals
We will include an
Array
type for slice literals:And a
Map
type for map literals:Composition
Apply2, Apply3, and more
We will include variations of
Apply
that accept a varying number ofpux.Input
arguments. The type signatures get a bit messy, but they look like the following:These will act as type-safe replacements of
pulumi.All
when the number of arguments is fixed.All
The previous section mentioned replacing
pulumi.All
withpux.Apply{2,8}
. That was not entirely accurate. Thepux.Apply*
functions serve a different purpose thanpulumi.All
: they encapsulate the common use case of callingpu.All
and immediately transforming the result with apply.We still need a type-safe
pulumi.All
operation analog. However, it's not currently possible in Go to write a function parameterized over a variadic number of type parameters.We'll introduce
pux.All
. It will operate exactly the same as the existingpu.All
but with slightly more type safety: it'll expect a series ofInput[any]
arguments, and it will return anOutput[[]any]
.Note that
Output[T]
is not anInput[any]
. To facilitate usages like this one, we'll introduce anAsAny()
method onpux.Output
.Example usage:
Containers
The APIs defined above are sufficient to provide a type-safe Pulumi experience. However, we are also hoping to address excessive code generation and redundant container types with this proposal.
We will generate generic output types to encapsulate pointer, array, and map outputs of any other type. These will satisfy
pux.Input[...]
for their corresponding container type. These types will provide convenience accessors similar to those we generate today:This alone isn't enough to replace the redundant output types, though. We need the ability to return output implementations other than
Output[T]
to retain the ability to chain such attribute accesses:Therefore, we will include general variants of these three output types with a second type parameter,
O
indicating the output implementation they should return.GPtrOutput[T, O]
Input[*T]
Elem() O
GArrayOutput[T, O]
Input[[]T]
Index(Input[int]) O
GMapOutput[T, O]
Input[map[string]T]
MapIndex(Input[string]) O
O
will satisfy a new constraintOutputOf[T]
, guaranteeing that it is an output implementation, not merely an input.Constructing container outputs
We will include the following APIs to convert
Input[T]
values holding pointers, arrays, or maps into the corresponding specialized generic output type:Additionally, we'll include a
PtrOf
function to convert an existing output value into a pointer of that value. This is necessary for the frequent need where you have anOutput[string]
and an API expects anInput[*string]
.API Summary
In summary, this proposal suggests the following new APIs.
It additionally proposes that:
ToOutput
ToOutput
method on all input and output typesOff-ramp
This section discusses how we'll be able to use the new APIs to slowly transition from untyped to typed APIs.
To off-ramp from untyped APIs to type-safe APIs across the ecosystem, we'll have the following phases.
These phases are discussed in more detail below.
Phase 1: Generics rollout
During this phase, we will release the proposed APIs into the Pulumi Core SDK. In the same release, we will change the SDK generator to include
ToOutput
methods for generated concrete types. Individual providers will pick up this new functionality as they update to the latest Pulumi release. Once all known providers are on this release, we may optionally deprecate (but not delete) the untyped APIs.Note that this is entirely backwards compatible. For example, consider aws/s3. With these changes, the package will look exactly the same except methods like the following will be added where relevant:
This will be sufficient for end users of Pulumi to begin using
pux.Apply
and friends with these types.Phase 2: Provider migration
Commencement of this phase is contingent on the following:
ToOutput
methods for all their input and output types.In this phase, providers that are willing to accept a breaking change in their public APIs will opt into the new definitions by activating a flag on the SDK generator. With this flag enabled, the SDK generator will change all generated APIs to accept
pux.Input
types, and returnpux.Output
orpux.[G]{Array,Map,Ptr}Output
values.Migrating provider outputs
Going back to the S3 example, s3.AccessPoint will produce
pux.Output[string]
,pux.Output[bool]
, etc. instead of its current output fields.This is a breaking change to the
AccessPoint
struct. The types of its fields have changed. At least the following sites will require updating:Direct references to the field types. For example:
Passing these fields as input to APIs that haven't switched to the new APIs. For instance, suppose command/local has yet to switch and expects a
pu.StringPtrInput
for one of its fields. We'll have to use thepux.Cast
function. (See Downgrading for details.)Existing
Output.ApplyT
calls to these fields will require no changes.Migrating provider output structs
The example above did not include output structs. We'll discuss those here. Pulumi currently generates convenience accessors for output structs--see Struct data in v3 for details. We don't want to lose the ability to use these accessors.
Therefore, we will continue to generate output types for struct types. However, we will use the
GPtrOutput
,GMapOutput
, andGArrayOutput
types defined above for all containers.For example:
Alternatively, there's an opportunity here to replace accessor methods with direct attribute access. See Directly accessible output struct fields.
Migrating provider inputs
Similarly to the outputs, points where the SDK previously accepted built-in or generated input types, it will expect
pux.Input
types.For instance:
The same change will be made to other structs and functions that accept input values. This is a breaking change, and any direct references to the types of these fields will need to be updated.
However, most assignments to these fields will require no changes because both, old output types (
pu.StringPtrOutput
) and new output types (pux.Output[*string]
) will satisfy the corresponding input interface (pux.Input[*string]
), and therefore be assignable to those fields as-is.Phase 3: Pulumi v4
When at some future date, we're willing to make a breaking change to Pulumi, we will delete a bulk of the old APIs defined in the top-level
pulumi
package. At least types with names matching the following regular expressions will be deleted:The
Input
andOutput
types in particular will be replaced by their type-safe generic variants.This will be accompanied by a matching change to the code generators.
Future work
This section lists extensions we could add on top of the proposed APIs with varying levels of confidence on each item.
Apply2, Apply3, and friends for other SDKs
To keep the high-level operations consistent across SDKs, we'll introduce analogs of
pux.Apply2
,pux.Apply3
, and the rest to other SDKs. These will act as a shortcut over theall(...).apply(...)
pattern:The design of this functionality for other SDKs is out of scope for this document.
Type-safe output specialization
To implement some of the functionality defined above, we'll need a function that is able to upgrade an
Output[T]
into an arbitrary output typeO
as long as it satisfiesOutputOf[T]
. Let's refer to this ascast
for now:As an example, this function is used in
GPtrOutput
like so:This function is likely to be valuable to users to perform type-safe upgrades and downgrades during Phase 1 where they have code mixing the typed and untyped APIs.
For example:
We should consider exporting this function as
pux.Cast
.Directly accessible output struct fields
See Structured data in v3 for what we currently do for struct output types.
@AaronFriel suggested an alternative representation for output structs which would obviate the need for accessor methods. Since we get only one chance to make a breaking change to how outputs are consumed, we should consider bundling this change in with Phase 2.
In short, the idea is that instead of generating standard output types that embed
*OutputState
, we will flatten all nested structs so that only primitive and container fields use actual output types.For example, we currently plan on generating the following after this proposal:
Instead, we would generate the following:
This will allow direct access to fields of the various structs.
Some details remain to be ironed out here, but we should give this full consideration before we make any code generation changes.
Array and Map Filtering
We can introduce Filter methods to the
pux.ArrayOutput
andpux.MapOutput
types.These will return new equivalent output types with only those items in them for which
keep
returned true.Type-safe tuples
To augment
pux.All
for a fixed number of parameters, we should consider adding functionsAll[N]
functions for N 2-8. These will returnTuple[N]Output
objects holdingTuple[N]
values. For example:The above gives us a means of grouping a pair of input values into a single output value in a type-safe manner. Keeping with the pattern of Apply2 and friends, we will generate variants of this up to 8 in the core Pulumi SDK.
Type-safe output composition
As an alternative to Type-safe tuples, @t0yv0 shared a prototype from prior experimentation as inspiration.
He suggested a callback-based API that allows composition of an arbitrary number of outputs. Roughly, the new API surface would be:
Key details:
*O
*O
and an input value, and returns its result*O
internally tracks output state for the result ofCompose
Together, these allow arbitrarily complex composition of output values. For example:
Some details about how this would work are unclear at this time.
FAQ
Why embed OutputState instead of Output?
The marshaling logic requires that the OutputState type be embedded into custom output types. This is fixable, but even without this restriction, there's some additional flexibility afforded by not requiring types to embed a typed output.
Can we delete the Input type?
We've eliminated the
Input
type in the Java SDK, but we cannot do the same for Go as part of this redesign.The
Input[T]
provides us with polymorphism on the kinds of values we'll accept in place of anOutput[T]
. This facilitates at least the following features:We allow upgrading
StringOutput
topux.Output[string]
transparently.We're able to provide type-safe container types.
We can leverage composite literals for containers.
We give users flexibility to define their own helpers. For example, a user could write:
Why is
Input
a type constraint forApply
and friends?In Apply and similar functions, we use
Input[I]
as a type constraint. That is, we do the following:However, the following is also a valid way to write
Apply
. It treatsInput[T]
as a type.We chose the former because it allows the type system to infer the value of type parameter
I
from the type of the input value.If we chose the latter form, Go's type system will not infer type parameter
I
, and users will have to set it manually:(This may improve in future versions of Go, e.g. with golang/go#58650.)
Why don't we use generics to have a single
Apply
function?It's possible with generics to write a single
Apply
function that accepts both, fallible and infallible appliers.Unfortunately, this leaves the type system insufficient information in any invocation of
Apply
to infer the types ofI
andO
in a simple call like the following:Simplifying this further—no input constraint—even the following is insufficient:
If we invert the constraints to move
O
first and set it, that is sufficient:However, the inconvenience of having to supply the type name repeatedly outweighs the benefit of this overloading—it's not much better than casting the output of
ApplyT
.Why are Join's type parameters out of order?
Go's type inference is currently unable to infer its type in a snippet like the following:
Since the type parameter is required anyway, we make the function's usage slightly easier by making it the first parameter. A caller will be able to use it after supplying just that parameter, and not
I
.Why do we need GPtrOutput and friends?
The
GPtrOutput
,GArrayOutput
, andGMapOutput
types are necessary so that accessor methods know the kind of value to return. Without this, we would lose the ability to chain these outputs together.For example, suppose that we drop these types and have just the plain variants:
Their accessors return
Output[T]
:Given a list of maps, we will be unable to chain these calls:
Compare that with the two-parameter version which is able to do this:
Appendix
Hackathon prototype
The prototyped API that came out of a prior Hackathon for introducing generics to the Go SDK can be minimized to the following:
It added a new Promise type.
This would act as both, an
Input
and anOutput
for the typeT
.It includes the following basic operations for type-conversion.
The
OutputState.ApplyT
method is turned into a top-level function because Go doesn't support method-level generics, and aFlatMap
function is added for functions which return promises.Other functions included in the prototype have been omitted as most of them can be constructed from the functions defined above.
Concepts in v3
This section summarizes the core concepts (Input, Output, and Apply) as they are in Pulumi Go v3. Some details have been omitted for brevity.
The Input and Output interfaces are defined as follows.
Custom outputs
The
OutputState
struct holds most of the implementation of anOutput
—all but theElementType
.For any type meant to be passed as an output, a user—or more often, a code generator—writes a new struct type that embeds
*OutputState
. Embedding creates delegates of all methods on OutputState on this struct, and it implementElementType
separately. For example:Each such output type is registered with the Pulumi SDK using RegisterOutputType.
This registration information is used by functions like ToOutput and methods like ApplyT to decide the output implementation they should return for a given type.
Custom inputs
To implement an input type, the system generates an interface that embeds
Input
, and includes aTo[Type]Output
method, as well as their context and pointer variants if appropriate.Input types are similarly registered with the Pulumi SDK with the RegisterInputType˙ function.
Outputs of outputs
Output.ApplyT
eagerly unwraps outputs nested inside output values: if the applier function returns an output value, it unwraps it.Note that the result is an
IntOutput
, not anAnyOutput
value containing anIntOutput
value.Structured data in v3
Pulumi's SDK generator generates output types for every new type, pointer, and collection it encounters. In these output types, it includes convenience methods to access their sub-items as output-wrapped values.
For structs, it generates an output type with field accessors. For example:
For pointer types, it generates an analog of the struct output type:
For arrays, it generates an output type with an
Index
method to access specific positions in the array.For maps, it generates an output type with a
MapIndex
method.Note that because maps and arrays return the specialized output type for that struct, users are able to perform chained access:
Edits
pux.[Must]Cast
topux.[Must]ConvertTyped
and renamedpux.SpecializeOutput
topux.Cast
.pux.Cast[pu.StringOutput](o)
overo.Untyped().(pu.StringOutput)
Beta Was this translation helpful? Give feedback.
All reactions