Skip to content

Generate typed interfaces for discriminated unions - #2267

Open
lukebward wants to merge 4 commits into
mainfrom
lward/discriminated-union-typing
Open

Generate typed interfaces for discriminated unions#2267
lukebward wants to merge 4 commits into
mainfrom
lward/discriminated-union-typing

Conversation

@lukebward

@lukebward lukebward commented Aug 7, 2026

Copy link
Copy Markdown

Fixes #2266. Mirrors pulumi/pulumi-dotnet#1107 and pulumi/pulumi-dotnet#1108 for Java.

Neither shape Java generates for a discriminated union can be dispatched on the discriminator. A oneOf of three or more object types generated as Object, so any value compiled and mistakes surfaced at deploy time, or not at all. A two-member one generated Either<L, R>, which is worse than it looks: Converter.tryConvertOneOf picks an arm with canBeCast, which is isInstance, and the wire value for a struct is a map, so neither arm matches and the property deserializes to null. EitherConverterTest only ever covered Either<Integer, String>, where the value already has its final Java type.

Three related changes:

1. Typed interfaces, behind a new fullyTypedUnions option. Each discriminated union gets an interface carrying the discriminator and its mapping; members implement it, and union-typed properties use it. From the mini-azurenative golden:

-import java.lang.Object;
-    private Object properties;
+    private IGetActionRuleByNameProperties properties;
@DiscriminatedUnion("type")
@DiscriminatedUnion.Case(tag = "ActionGroup", type = ActionGroupResponse.class)
@DiscriminatedUnion.Case(tag = "Diagnostics", type = DiagnosticsResponse.class)
@DiscriminatedUnion.Case(tag = "Suppression", type = SuppressionResponse.class)
public interface IGetActionRuleByNameProperties {
}

Typing a property that used to be Object or Either breaks the callers of a generated SDK, and only the provider knows when it is cutting a major version, so a package opts in through language.java.fullyTypedUnions. Without it the generated output is byte-identical to today.

Member count takes no part in the decision. An earlier revision converted only unions of three or more members, which made adding a third member to an existing union a breaking change. Any discriminated union qualifies now, whatever its size, provided the mapping covers every member and the members share a module. Undiscriminated unions are untouched: with no tag there is nothing to dispatch on, so enum | string keeps Either<L, R>.

2. Const-valued properties are filled in by generated builders, so the discriminator tag never has to be written by hand. This applies with or without the option. It was the loudest complaint when the service team reviewed the equivalent .NET output.

3. Deserialization dispatches on the discriminator tag instead of canBeCast, which cast by shape and therefore could not distinguish two variants whose property shapes are identical.

Test plan

  • New codegen unit tests: interface emission, subset-interface inheritance, const initialization, a two-member union with the option on, and a negative test asserting a package without the option is unchanged. All pass.
  • TestGeneratePackage/mini-azurenative passes, including its java/compile step. That schema sets the option, so it is the golden that exercises the union end to end.
  • output-funcs does not set the option, since its schema is a symlink into the pulumi submodule, so it covers the un-opted-in path.
  • New runtime tests in PropertyValueSerializerTest and DiscriminatedUnionConverterTest cover tag dispatch.

Three pre-existing failures are unrelated to this change: TestGeneratePackage/{jumbo-resources,mini-awsclassic,mini-kubernetes}/java/test. I confirmed they fail identically on a clean checkout of the parent commit with no modifications. None of those three schemas carries a discriminator, and none of their goldens changed here.

Note for reviewers

pulumi/pulumi#24213 adds l2-discriminated-union-internal to the conformance suite, and pulumi/pulumi#24190 added l2-discriminated-union-many. Neither is in a released pulumi/pkg yet, so this PR is verified against codegen goldens and unit tests rather than conformance. Both will also need the schemas to set fullyTypedUnions before they exercise the Java path at all.

When the pin next moves, note that l2-discriminated-union-many assigns one resource's output into another's input where the output's union is a subset of the input's. That case fails in both .NET (CS0029) and Python (mypy) for a variance reason. Java will likely need an expected-failure entry too, but for a different cause: the output side is generated in the plain shape and the input side in the Args shape, and those two class families are unrelated for every type, not just unions. Generic invariance on Output<T> blocks it independently.

Scope note on the const commit

Supplying const-valued properties applies to every schema property carrying a const, not only discriminator tags. The visible effect is one line in the constant conformance snapshot, updated in this PR.

A oneOf union of three or more object types was generated as Object, even
though the schema carries a discriminator with a complete mapping. Generate
an interface per union, annotated with the discriminator property and its
tag-to-type mapping; members implement it, and union-typed properties use it.
Unions with two members are unchanged.

Builders now fill in const-valued properties, so the discriminator tag never
has to be written by hand.

Deserialization resolves a union by reading the discriminator tag instead of
casting by shape, which could not distinguish two variants with the same
property shape.

Fixes #2266.
@lukebward
lukebward requested a review from a team as a code owner August 7, 2026 16:33
Generated builders now supply const-valued properties as a default, so the
constant test's SDK snapshot changes accordingly.
The mapping-coverage guard compared a tag count against a member count.
Two tags naming the same member kept those equal while leaving another
member uncovered, so it was dropped from the interface and could never be
deserialized. Compare the set of covered tokens instead.

Discriminator names and tags come from the schema and are emitted inside
Java string literals, so escape them.

Reserve the identifier that is actually emitted: names.Ident normalizes
away characters such as '-', so two distinct candidates could collide
after normalization while the uniqueness check saw them as different.

Point the changelog entries at this PR.
@iwahbe

iwahbe commented Aug 15, 2026

Copy link
Copy Markdown
Member

How common are discriminated unions with < 3 members? This implementation makes adding a 3rd member to such a union breaking, which I think is surprising. If the registry doesn't have any discriminated unions now with 1 or 2 members, I think we should consider a break here.

If the feature is used, then we might have to deal with the incongruity between unions of different sizes.

@Frassle

Frassle commented Aug 15, 2026

Copy link
Copy Markdown
Member

If the feature is used, then we might have to deal with the incongruity between unions of different sizes.

It's a break change anyway going from object -> typed thing probably. I wonder if we should add a flag to let providers opt-in to this break change. fullyTypedUnions option for each language in the Language section of the schema as we add support for each language. And then make it apply to 1-2 element unions as well.

Member count no longer decides how a discriminated union is generated, so
adding a member to one is not a breaking change. A package opts in through
language.java.fullyTypedUnions; without it every union keeps the shape it
has today.

@unblocked unblocked Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues found

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

@lukebward

lukebward commented Aug 17, 2026

Copy link
Copy Markdown
Author

At least for a provider like azure-native, there's a few hundred < 3 member discriminated unions, and also a few hundred that are 3+. So, both are large.

fullyTypedUnions option for each language in the Language section of the schema as we add support for each language. And then make it apply to 1-2 element unions as well.

Would the idea be with this flag enabled, we break at 1-2 elements? And just use Interfaces from the start?

@iwahbe

iwahbe commented Aug 17, 2026

Copy link
Copy Markdown
Member

At least for a provider like azure-native, there's a few hundred < 3 member discriminated unions, and also a few hundred that are 3+. So, both are large.

Unfortunate.

fullyTypedUnions option for each language in the Language section of the schema as we add support for each language. And then make it apply to 1-2 element unions as well.

Would the idea be with this flag enabled, we break at 1-2 elements? And just use Interfaces from the start?

The idea is with this flag enabled, we do the interface for all unions. Without it enabled, nothing changes. Going from untyped to typed is also a breaking change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Discriminated unions collapse to Object; const tags are not pre-filled; output deserialization casts by shape

3 participants