Skip to content

[emitter-framework] Render discriminated unions correctly #7369

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
May 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .chronus/changes/discriminated-union-ef-2025-4-15-22-11-29.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/emitter-framework"
---

Render discriminated unions correctly
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function InterfaceBody(props: TypedInterfaceDeclarationProps): Children {

return (
<>
<ay.For each={validTypeMembers} line {...enderProp}>
<ay.For each={validTypeMembers} semicolon line {...enderProp}>
{(typeMember) => {
return <InterfaceMember type={typeMember} />;
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as ay from "@alloy-js/core";
import { Children } from "@alloy-js/core";
import * as ts from "@alloy-js/typescript";
import { Enum, EnumMember, Union, UnionVariant } from "@typespec/compiler";
import { compilerAssert, Enum, EnumMember, Union, UnionVariant } from "@typespec/compiler";
import { useTsp } from "../../core/context/tsp-context.js";
import { efRefkey } from "../utils/refkey.js";
import { TypeExpression } from "./type-expression.jsx";

export interface UnionExpressionProps {
Expand All @@ -19,11 +20,30 @@ export function UnionExpression({ type, children }: UnionExpressionProps) {

const variants = (
<ay.For joiner={" | "} each={items}>
{(_, value) => {
if ($.enumMember.is(value)) {
return <ts.ValueExpression jsValue={value.value ?? value.name} />;
} else {
return <TypeExpression type={value.type} />;
{(_, type) => {
if ($.enumMember.is(type)) {
return <ts.ValueExpression jsValue={type.value ?? type.name} />;
}

const discriminatedUnion = $.union.getDiscriminatedUnion(type.union);
switch (discriminatedUnion?.options.envelope) {
case "object":
return (
<ObjectEnvelope
discriminatorPropertyName={discriminatedUnion.options.discriminatorPropertyName}
envelopePropertyName={discriminatedUnion.options.envelopePropertyName}
type={type}
/>
);
case "none":
return (
<NoneEnvelope
discriminatorPropertyName={discriminatedUnion.options.discriminatorPropertyName}
type={type}
/>
);
default:
return <TypeExpression type={type.type} />;
}
}}
</ay.For>
Expand All @@ -32,10 +52,83 @@ export function UnionExpression({ type, children }: UnionExpressionProps) {
if (children || (Array.isArray(children) && children.length)) {
return (
<>
{variants} {` | ${children}`}
{variants} {`| ${children}`}
</>
);
}

return variants;
}

interface ObjectEnvelopeProps {
type: UnionVariant;
discriminatorPropertyName: string;
envelopePropertyName: string;
}

/**
* Renders a discriminated union with "object" envelope style
* where model properties are nested inside an envelope
*/
function ObjectEnvelope(props: ObjectEnvelopeProps) {
const { $ } = useTsp();

const envelope = $.model.create({
properties: {
[props.discriminatorPropertyName]: $.modelProperty.create({
name: props.discriminatorPropertyName,
type: $.literal.createString(props.type.name as string),
}),
[props.envelopePropertyName]: $.modelProperty.create({
name: props.envelopePropertyName,
type: props.type.type,
}),
},
});

return <TypeExpression type={envelope} />;
}

interface NoneEnvelopeProps {
type: UnionVariant;
discriminatorPropertyName: string;
}

/**
* Renders a discriminated union with "none" envelope style
* where discriminator property sits alongside model properties
*/
function NoneEnvelope(props: NoneEnvelopeProps) {
const { $ } = useTsp();

compilerAssert(
$.model.is(props.type.type),
"Expected all union variants to be models when using a discriminated union with no envelope",
);

// Render anonymous models as a set of properties + the discriminator
if ($.model.isExpresion(props.type.type)) {
const model = $.model.create({
properties: {
[props.discriminatorPropertyName]: $.modelProperty.create({
name: props.discriminatorPropertyName,
type: $.literal.createString(props.type.name as string),
}),
...Object.fromEntries(props.type.type.properties),
},
});
return <TypeExpression type={model} />;
}

return (
<ay.List joiner={" & "}>
<ts.ObjectExpression>
<ts.ObjectProperty
name={props.discriminatorPropertyName}
value={<ts.ValueExpression jsValue={props.type.name} />}
/>
</ts.ObjectExpression>
<>{efRefkey(props.type.type)}</>
</ay.List>
);
}
Loading
Loading