Skip to content
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
8 changes: 4 additions & 4 deletions generators/python/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -184,22 +184,28 @@ function createMockGeneratedAliasType(opts?: { isBranded?: boolean }): Generated
}

/**
* Creates a mock GeneratedType for union schemas.
* Creates a mock GeneratedType for union schemas. Optionally accepts the union's
* base properties so callers exercising `_Base` can have them returned from
* `getEffectiveBaseProperties`. The mock skips the real suppression logic
* (which requires variant type lookups via context) and just passes them
* through — adequate for these snapshot tests.
*/
function createMockGeneratedUnionType(): GeneratedType<unknown> {
return {
type: "union",
getGeneratedUnion: () => ({
discriminant: "type",
visitPropertyName: "_visit",
getReferenceTo: () => ts.factory.createTypeReferenceNode("ParsedUnion"),
buildUnknown: ({ existingValue }: { existingValue: ts.Expression }) => existingValue,
buildFromExistingValue: ({ existingValue }: { existingValue: ts.Expression }) => existingValue,
getBasePropertyKey: (wireValue: string) => wireValue
}),
getSinglePropertyKey: ({ name }: { name: FernIr.NameAndWireValue }) => name.wireValue
// biome-ignore lint/suspicious/noExplicitAny: test mock with minimal GeneratedType interface
} as any;
function createMockGeneratedUnionType(baseProperties: FernIr.ObjectProperty[] = []): () => GeneratedType<unknown> {
return () =>
({
type: "union",
getGeneratedUnion: () => ({
discriminant: "type",
visitPropertyName: "_visit",
getReferenceTo: () => ts.factory.createTypeReferenceNode("ParsedUnion"),
getEffectiveBaseProperties: () => baseProperties,
buildUnknown: ({ existingValue }: { existingValue: ts.Expression }) => existingValue,
buildFromExistingValue: ({ existingValue }: { existingValue: ts.Expression }) => existingValue,
getBasePropertyKey: (wireValue: string) => wireValue
}),
getSinglePropertyKey: ({ name }: { name: FernIr.NameAndWireValue }) => name.wireValue
// biome-ignore lint/suspicious/noExplicitAny: test mock with minimal GeneratedType interface
}) as any;
}

/**
Expand Down Expand Up @@ -365,7 +371,7 @@ describe("TypeSchemaGenerator", () => {
const schema = generator.generateTypeSchema({
typeName: "Shape",
shape,
getGeneratedType: createMockGeneratedUnionType,
getGeneratedType: createMockGeneratedUnionType(),
getReferenceToGeneratedType: () => ts.factory.createTypeReferenceNode("Shape"),
getReferenceToGeneratedTypeSchema: () => createMockReference("ShapeSchema")
});
Expand Down Expand Up @@ -444,7 +450,7 @@ describe("TypeSchemaGenerator", () => {
const schema = generator.generateTypeSchema({
typeName: "Shape",
shape,
getGeneratedType: createMockGeneratedUnionType,
getGeneratedType: createMockGeneratedUnionType(),
getReferenceToGeneratedType: () => ts.factory.createTypeReferenceNode("Shape"),
getReferenceToGeneratedTypeSchema: () => createMockReference("ShapeSchema")
});
Expand Down Expand Up @@ -925,7 +931,7 @@ describe("GeneratedUnionTypeSchemaImpl", () => {
default: undefined,
discriminatorContext: undefined
},
getGeneratedType: createMockGeneratedUnionType,
getGeneratedType: createMockGeneratedUnionType(opts.baseProperties ?? []),
getReferenceToGeneratedType: () => ts.factory.createTypeReferenceNode(opts.typeName),
getReferenceToGeneratedTypeSchema: () => createMockReference(`${opts.typeName}Schema`),
noOptionalProperties: opts.noOptionalProperties ?? false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ export class GeneratedUnionImpl<Context extends ModelContext> implements Generat
if (this.includeUtilsOnUnionMembers || this.includeConstBuilders) {
statements.push(this.getVisitorInterface(context));
}
if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
const baseInterface = this.getBaseInterface(context);
statements.push(baseInterface.normal);
if (baseInterface.request || baseInterface.response) {
Expand Down Expand Up @@ -564,12 +564,50 @@ export class GeneratedUnionImpl<Context extends ModelContext> implements Generat
};
}

/**
* Property names that every variant already inherits via its `extends` chain.
* The OpenAPI parser lifts these onto the union's `baseProperties` so that
* generators without structural typing (Go, C#) can expose them directly, but
* for TypeScript re-emitting them on `_Base` would conflict with the real
* parent interface — TS2320 if optionality disagrees. Suppress them here.
*/
private getInheritedPropertyNamesSharedByAllVariants(context: Context): Set<string> {
if (this.shape == null || this.shape.types.length === 0) {
return new Set();
}
let intersection: Set<string> | undefined;
for (const variant of this.shape.types) {
const variantInherited = new Set<string>();
if (variant.shape.propertiesType === "samePropertiesAsObject") {
const declaration = context.type.getTypeDeclaration(variant.shape);
if (declaration.shape.type === "object") {
for (const property of declaration.shape.extendedProperties ?? []) {
variantInherited.add(getWireValue(property.name));
}
}
}
if (intersection == null) {
intersection = variantInherited;
} else {
for (const name of intersection) {
if (!variantInherited.has(name)) {
intersection.delete(name);
}
}
}
if (intersection.size === 0) {
return intersection;
}
}
return intersection ?? new Set();
}

private getBaseInterface(context: Context): {
normal: InterfaceDeclarationStructure;
request: InterfaceDeclarationStructure | undefined;
response: InterfaceDeclarationStructure | undefined;
} {
const properties = this.baseProperties.map((p) => {
const properties = this.getEffectiveBaseProperties(context).map((p) => {
const type = context.type.getReferenceToType(p.valueType);
const property = {
name: getPropertyKey(this._getBasePropertyKey(p)),
Expand Down Expand Up @@ -739,7 +777,7 @@ export class GeneratedUnionImpl<Context extends ModelContext> implements Generat
}

private addBuilderProperties(context: Context, writer: ObjectWriter) {
if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
throw new Error("Cannot create builders because union has base properties");
}

Expand Down Expand Up @@ -870,16 +908,25 @@ export class GeneratedUnionImpl<Context extends ModelContext> implements Generat
return [...this.parsedSingleUnionTypes, this.unknownSingleUnionType];
}

private hasBaseInterface(): boolean {
return this.baseProperties.length > 0 || (this.shape?.extends ?? []).length > 0;
public getEffectiveBaseProperties(context: Context): FernIr.ObjectProperty[] {
const suppressed = this.getInheritedPropertyNamesSharedByAllVariants(context);
if (suppressed.size === 0) {
return this.baseProperties;
}
return this.baseProperties.filter((p) => !suppressed.has(getWireValue(p.name)));
}

private hasBaseInterface(context: Context): boolean {
return this.getEffectiveBaseProperties(context).length > 0 || (this.shape?.extends ?? []).length > 0;
}

private hasBaseInterfaces(context: Context): {
normal: boolean;
request: boolean;
response: boolean;
} {
const hasNormal = this.baseProperties.length > 0 || (this.shape?.extends ?? []).length > 0;
const effectiveBaseProperties = this.getEffectiveBaseProperties(context);
const hasNormal = effectiveBaseProperties.length > 0 || (this.shape?.extends ?? []).length > 0;
if (!this.generateReadWriteOnlyTypes) {
return {
normal: hasNormal,
Expand All @@ -891,7 +938,7 @@ export class GeneratedUnionImpl<Context extends ModelContext> implements Generat
let hasRequest = false;
let hasResponse = false;
if (hasNormal && this.shape) {
const properties = this.baseProperties.map((p) => {
const properties = effectiveBaseProperties.map((p) => {
const type = context.type.getReferenceToType(p.valueType);
return {
requestProperty: type.requestTypeNode ? true : false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract

public override generateRawTypeDeclaration(context: Context, module: ModuleDeclaration): void {
const interfaces = this.singleUnionTypes.map((singleUnionType) => singleUnionType.generateInterface(context));
const effectiveBaseProperties = this.getEffectiveBaseProperties(context);

module.addTypeAlias({
name: AbstractGeneratedSchema.RAW_TYPE_NAME,
Expand All @@ -81,15 +82,15 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract

for (const interfaceStructure of interfaces) {
const interface_ = module.addInterface(interfaceStructure);
if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
interface_.insertExtends(0, GeneratedUnionSchema.BASE_SCHEMA_NAME);
}
}

if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
module.addInterface({
name: GeneratedUnionSchema.BASE_SCHEMA_NAME,
properties: this.baseProperties.map((property) => {
properties: effectiveBaseProperties.map((property) => {
const type = context.typeSchema.getReferenceToRawType(property.valueType);
return {
name: getPropertyKey(getWireValue(property.name)),
Expand All @@ -116,7 +117,7 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract
rawDiscriminant: getWireValue(this.discriminant),
singleUnionTypes: this.singleUnionTypes.map((singleUnionType) => {
const singleUnionTypeSchema = singleUnionType.getSchema(context);
if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
singleUnionTypeSchema.nonDiscriminantProperties =
singleUnionTypeSchema.nonDiscriminantProperties.extend(
context.coreUtilities.zurg.Schema._fromExpression(
Expand Down Expand Up @@ -205,7 +206,7 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract
return;
}

if (this.hasBaseInterface()) {
if (this.hasBaseInterface(context)) {
context.sourceFile.addVariableStatement({
declarationKind: VariableDeclarationKind.Const,
declarations: [
Expand All @@ -214,7 +215,7 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract
initializer: getTextOfTsNode(
context.coreUtilities.zurg
.object(
this.baseProperties.map((baseProperty) => ({
this.getEffectiveBaseProperties(context).map((baseProperty) => ({
key: {
raw: getWireValue(baseProperty.name),
parsed: this.getGeneratedUnion(context).getBasePropertyKey(
Expand Down Expand Up @@ -316,7 +317,11 @@ export class GeneratedUnionSchema<Context extends ModelContext> extends Abstract
});
}

private hasBaseInterface(): boolean {
return this.baseProperties.length > 0 || (this.shape?.extends ?? []).length > 0;
private getEffectiveBaseProperties(context: Context): FernIr.ObjectProperty[] {
return this.getGeneratedUnion(context).getEffectiveBaseProperties(context);
}

private hasBaseInterface(context: Context): boolean {
return this.getEffectiveBaseProperties(context).length > 0 || (this.shape?.extends ?? []).length > 0;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# yaml-language-server: $schema=../../../../../../fern-changes-yml.schema.json

- summary: |
The TypeScript SDK generator now suppresses lifted base properties from a discriminated union's synthesized `_Base` interface when every variant already inherits them via its `extends` chain. Pairs with the parser change that lifts shared-parent properties so non-structural-typing SDKs (Go, C#) can expose them; keeps the TypeScript output stable and avoids TS2320 collisions on optionality.
type: fix
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function createMockFileContext() {
getReferenceTo: () => ts.factory.createTypeReferenceNode("ErrorUnion"),
discriminant: "errorName",
visitPropertyName: "_visit",
getEffectiveBaseProperties: () => [],
getBasePropertyKey: (key: string) => key,
buildFromExistingValue: ({ existingValue }: { existingValue: ts.Expression }) => existingValue,
buildUnknown: ({ existingValue }: { existingValue: ts.Expression }) => existingValue
Expand Down
7 changes: 7 additions & 0 deletions generators/typescript/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 3.72.4
changelogEntry:
- summary: |
The TypeScript SDK generator now suppresses lifted base properties from a discriminated union's synthesized `_Base` interface when every variant already inherits them via its `extends` chain. Pairs with the parser change that lifts shared-parent properties so non-structural-typing SDKs (Go, C#) can expose them; keeps the TypeScript output stable and avoids TS2320 collisions on optionality.
type: fix
createdAt: "2026-06-16"
irVersion: 67
- version: 3.72.3
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import { FernIr } from "@fern-fern/ir-sdk";
import { ts } from "ts-morph";

export interface GeneratedUnion<Context> {
discriminant: string;
visitPropertyName: string;
getReferenceTo: (context: Context) => ts.TypeNode;
/**
* Returns base properties that should be emitted on the union, with any
* properties already inherited via every variant's `extends` chain
* suppressed. Lets sibling generators (e.g. the schema layer) stay in
* sync with the type layer so the synthesized `_Base` never collides
* with a real parent interface (TS2320).
*/
getEffectiveBaseProperties: (context: Context) => FernIr.ObjectProperty[];
build: (args: {
discriminantValueToBuild: string | number;
builderArgument: ts.Expression | undefined;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function commonPropertyOptionalityOf(schema: Schema | undefined, key: string): "
}

describe("infer-discriminated-union-base-properties", () => {
it("does NOT infer properties already inherited via a shared allOf $ref parent", () => {
it("infers properties inherited via a shared allOf $ref parent so non-structural-typing SDKs can expose them", () => {
const doc: OpenAPIV3.Document = {
openapi: "3.0.0",
info: { title: "Test API", version: "1.0" },
Expand Down Expand Up @@ -135,9 +135,12 @@ describe("infer-discriminated-union-base-properties", () => {
const keys = commonPropertyKeysOf(union);

// `sharedField` and `anotherShared` come from Common, which every variant inherits
// via `allOf: $ref`. They should NOT be re-emitted as union commonProperties.
expect(keys).not.toContain("sharedField");
expect(keys).not.toContain("anotherShared");
// via `allOf: $ref`. They are lifted onto the union so SDKs without structural
// typing (Go, C#, etc.) can expose them at the top level. Generators that
// synthesize a duplicate base interface (e.g. TypeScript) are responsible for
// suppressing the redeclaration to avoid TS2320 collisions.
expect(keys).toContain("sharedField");
expect(keys).toContain("anotherShared");
});

it("still infers properties that variants declare inline (not inherited from a shared parent)", () => {
Expand Down
Loading
Loading