From de4ca4577a4046349d2e0d24c6a0102c6234d764 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 12:19:28 -0400 Subject: [PATCH 1/6] fix(php): support non-nullable unions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP generation failed with "union are not supported" for any union that wasn't just nullable — a heterogeneous array like [1, "two", {...}] made the whole render throw. Unions are now rendered inline as PHP 8.0 union type declarations (e.g. `MixedClass|int|string`), so no named union class is emitted. The from/to/validate converters dispatch on the runtime type of the value with an if/elseif chain, testing the most specific checks first (instanceof for classes, enums and DateTime, then is_int before the float check, which also accepts integers) and throwing on unmatched values. Samples use the first member. On the JSON side classes and maps are stdClass and enums and dates are strings, with textual duplicates deduplicated in the declaration. This also adds the PHP reserved words to the renderer's forbidden names: the motivating fixture has a property named "mixed", which used to name its class `Mixed` — a compile error in PHP 8 (reserved type name), now renamed to `MixedClass`. Previously no reserved word was protected at all, so a JSON key like "class" or "print" with an object value generated uncompilable PHP. Generated output for the fixtures parses cleanly as PHP 8.0. Co-Authored-By: Claude Fable 5 --- .../src/language/Php/PhpRenderer.ts | 280 ++++++++++++++++-- .../quicktype-core/src/language/Php/utils.ts | 96 ++++++ test/unit/php-unions.test.ts | 67 +++++ 3 files changed, 424 insertions(+), 19 deletions(-) create mode 100644 test/unit/php-unions.test.ts diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index 816f03b53b..c8682f9199 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -20,21 +20,22 @@ import { type Sourcelike, maybeAnnotated } from "../../Source.js"; import { acronymStyle } from "../../support/Acronyms.js"; import { defined } from "../../support/Support.js"; import type { TargetLanguage } from "../../TargetLanguage.js"; -import type { - ClassProperty, - ClassType, - EnumType, - Type, +import { + type ClassProperty, + type ClassType, + type EnumType, + type Type, UnionType, } from "../../Type/index.js"; import { directlyReachableSingleNamedType, matchType, nullableFromUnion, + removeNullFromUnion, } from "../../Type/TypeUtils.js"; import type { phpOptions } from "./language.js"; -import { phpNameStyle, stringEscape } from "./utils.js"; +import { phpForbiddenClassNames, phpNameStyle, stringEscape } from "./utils.js"; export interface FunctionNames { readonly from: Name; @@ -65,6 +66,10 @@ export class PhpRenderer extends ConvenienceRenderer { super(targetLanguage, renderContext); } + protected forbiddenNamesForGlobalNamespace(): readonly string[] { + return phpForbiddenClassNames; + } + protected forbiddenForObjectProperties( _c: ClassType, _className: Name, @@ -88,8 +93,10 @@ export class PhpRenderer extends ConvenienceRenderer { return this.getNameStyling("enumCaseNamingFunction"); } - protected unionNeedsName(u: UnionType): boolean { - return nullableFromUnion(u) === null; + protected unionNeedsName(_u: UnionType): boolean { + // Unions are represented inline as PHP union type declarations; + // no named type is ever emitted for them. + return false; } protected namedTypeToNameForTopLevel(type: Type): Type | undefined { @@ -247,6 +254,179 @@ export class PhpRenderer extends ConvenienceRenderer { this.emitLine("}"); } + // Union members in runtime-dispatch order: the most specific check + // first, so that e.g. an int matches an integer member before a double + // member, and class instances are tested before the stdClass check a + // map member uses. `any` goes last because it matches everything. + private sortedUnionMembers(u: UnionType): { + members: readonly Type[]; + nullType: Type | null; + } { + const order = [ + "class", + "enum", + "date-time", + "uuid", + "map", + "array", + "bool", + "integer", + "double", + "string", + ]; + const [nullType, nonNulls] = removeNullFromUnion(u, (t) => { + const i = order.indexOf(t.kind); + return i === -1 ? order.length : i; + }); + return { members: Array.from(nonNulls), nullType }; + } + + // The PHP union type declaration for a non-nullable union, e.g. + // `MixedClass|int|string` (requires PHP 8.0). With `jsonSide` the + // types are those of the decoded JSON value instead, where classes + // and maps are stdClass and enums and dates are strings. + protected phpUnionType(u: UnionType, jsonSide: boolean): Sourcelike { + const labels: Record = jsonSide + ? { + any: "mixed", + array: "array", + bool: "bool", + class: "stdClass", + "date-time": "string", + double: "float", + enum: "string", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + } + : { + any: "mixed", + array: "array", + bool: "bool", + "date-time": "DateTime", + double: "float", + integer: "int", + map: "stdClass", + string: "string", + uuid: "string", + }; + const { members, nullType } = this.sortedUnionMembers(u); + const parts: Sourcelike[] = []; + const seen = new Set(); + for (const member of members) { + let hint: Sourcelike; + const label = labels[member.kind]; + if (label !== undefined) { + // Members can render to the same PHP type, like a string + // and a UUID member — mention it only once. + if (seen.has(label)) continue; + seen.add(label); + hint = label; + } else if (member.kind === "class" || member.kind === "enum") { + hint = this.nameForNamedType(member); + } else { + throw new Error( + `PHP cannot represent union member type "${member.kind}"`, + ); + } + + if (parts.length > 0) parts.push("|"); + parts.push(hint); + } + + if (nullType !== null) parts.push("|null"); + return parts; + } + + // The runtime check deciding whether a value is this union member. + // Returns null for `any`, which matches everything. With `jsonSide` + // the value is a decoded JSON value instead of a PHP-side one. + private unionMemberTypeCheck( + t: Type, + expr: Sourcelike[], + jsonSide: boolean, + ): Sourcelike[] | null { + switch (t.kind) { + case "null": + return ["is_null(", ...expr, ")"]; + case "bool": + return ["is_bool(", ...expr, ")"]; + case "integer": + return ["is_int(", ...expr, ")"]; + case "double": + // PHP integers are acceptable wherever floats are. + return ["is_float(", ...expr, ") || is_int(", ...expr, ")"]; + case "string": + case "uuid": + return ["is_string(", ...expr, ")"]; + case "date-time": + return jsonSide + ? ["is_string(", ...expr, ")"] + : [...expr, " instanceof DateTime"]; + case "enum": + return jsonSide + ? ["is_string(", ...expr, ")"] + : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "class": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "map": + return jsonSide + ? ["is_object(", ...expr, ")"] + : [...expr, " instanceof stdClass"]; + case "array": + return ["is_array(", ...expr, ")"]; + case "any": + return null; + default: + throw new Error( + `PHP cannot check for union member type "${t.kind}"`, + ); + } + } + + // Emits an if/elseif chain over the union's members, dispatching on + // the runtime type of `expr`, with `emitNoMatch` as the else branch. + private emitUnionDispatch( + u: UnionType, + expr: Sourcelike[], + jsonSide: boolean, + emitMember: (t: Type) => void, + emitNoMatch: () => void, + ): void { + const { members, nullType } = this.sortedUnionMembers(u); + const all = nullType === null ? members : [nullType, ...members]; + let first = true; + let haveCatchAll = false; + for (const member of all) { + const check = this.unionMemberTypeCheck(member, expr, jsonSide); + if (check === null) { + if (first) { + emitMember(member); + return; + } + + this.emitLine("} else {"); + this.indent(() => emitMember(member)); + haveCatchAll = true; + break; + } + + this.emitLine(first ? "if (" : "} elseif (", ...check, ") {"); + first = false; + this.indent(() => emitMember(member)); + } + + if (!haveCatchAll) { + this.emitLine("} else {"); + this.indent(emitNoMatch); + } + + this.emitLine("}"); + } + protected phpType( _reference: boolean, t: Type, @@ -276,7 +456,7 @@ export class PhpRenderer extends ConvenienceRenderer { const nullable = nullableFromUnion(unionType); if (nullable !== null) return this.phpType(true, nullable, true, prefix, suffix); - return this.nameForNamedType(unionType); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "time") { @@ -305,10 +485,20 @@ export class PhpRenderer extends ConvenienceRenderer { (_integerType) => "int", (_doubleType) => "float", (_stringType) => "string", - (arrayType) => [ - this.phpDocConvertType(className, arrayType.items), - "[]", - ], + (arrayType) => { + const itemsDoc = this.phpDocConvertType( + className, + arrayType.items, + ); + if ( + arrayType.items instanceof UnionType && + nullableFromUnion(arrayType.items) === null + ) { + return ["(", itemsDoc, ")[]"]; + } + + return [itemsDoc, "[]"]; + }, (_classType) => _classType.getCombinedName(), (_mapType) => "stdClass", (enumType) => this.nameForNamedType(enumType), @@ -321,7 +511,7 @@ export class PhpRenderer extends ConvenienceRenderer { ]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, false); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -356,7 +546,7 @@ export class PhpRenderer extends ConvenienceRenderer { return ["?", this.phpConvertType(className, nullable)]; } - throw new Error("union are not supported"); + return this.phpUnionType(unionType, true); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -435,7 +625,19 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + false, + (member) => + this.phpToObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Union value has no matching member in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -546,7 +748,19 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("union are not supported"); + this.emitUnionDispatch( + unionType, + args, + true, + (member) => + this.phpFromObjConvert(className, member, lhs, args), + () => + this.emitLine( + "throw new Exception('Cannot deserialize union value in ", + className, + "');", + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -717,7 +931,16 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error(`union are not supported:${unionType}`); + // Any member's sample is a valid sample for the union. + const { members } = this.sortedUnionMembers(unionType); + this.phpSampleConvert( + className, + defined(members[0]), + lhs, + args, + idx, + suffix, + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { @@ -832,7 +1055,26 @@ export class PhpRenderer extends ConvenienceRenderer { return; } - throw new Error("not implemented"); + this.emitUnionDispatch( + unionType, + [scopeAttrName], + false, + (member) => + this.phpValidate( + className, + member, + attrName, + scopeAttrName, + ), + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); }, (transformedStringType) => { if (transformedStringType.kind === "date-time") { diff --git a/packages/quicktype-core/src/language/Php/utils.ts b/packages/quicktype-core/src/language/Php/utils.ts index 457b0dfe35..fc027ca5a9 100644 --- a/packages/quicktype-core/src/language/Php/utils.ts +++ b/packages/quicktype-core/src/language/Php/utils.ts @@ -55,3 +55,99 @@ export function phpNameStyle( isStartCharacter, ); } + +// Words that cannot be used as class names in PHP, in the PascalCase form +// the type namer produces (PHP reserves them case-insensitively): keywords, +// plus the reserved type and constant names, plus the classes the generated +// code itself refers to. Class names produced from JSON property names +// like "mixed" or "class" would otherwise fail to compile. +export const phpForbiddenClassNames: readonly string[] = [ + "Abstract", + "And", + "Array", + "As", + "Bool", + "Break", + "Callable", + "Case", + "Catch", + "Class", + "Clone", + "Const", + "Continue", + "Converter", + "DateTime", + "DateTimeInterface", + "Declare", + "Default", + "Die", + "Do", + "Echo", + "Else", + "Elseif", + "Empty", + "Enddeclare", + "Endfor", + "Endforeach", + "Endif", + "Endswitch", + "Endwhile", + "Enum", + "Eval", + "Exception", + "Exit", + "Extends", + "False", + "Final", + "Finally", + "Float", + "Fn", + "For", + "Foreach", + "Function", + "Global", + "Goto", + "If", + "Implements", + "Include", + "Instanceof", + "Insteadof", + "Int", + "Interface", + "Isset", + "Iterable", + "List", + "Match", + "Mixed", + "Namespace", + "Never", + "New", + "Null", + "Object", + "Or", + "Parent", + "Print", + "Private", + "Protected", + "Public", + "Readonly", + "Require", + "Return", + "Self", + "Static", + "StdClass", + "String", + "Switch", + "Throw", + "Trait", + "True", + "Try", + "Unset", + "Use", + "Var", + "Void", + "While", + "Xor", + "Yield", + "stdClass", +]; diff --git a/test/unit/php-unions.test.ts b/test/unit/php-unions.test.ts new file mode 100644 index 0000000000..483cde8f72 --- /dev/null +++ b/test/unit/php-unions.test.ts @@ -0,0 +1,67 @@ +// PHP generation used to fail with "union are not supported" whenever the +// input contained a union that wasn't just nullable — e.g. a heterogeneous +// array like [1, "two", {"nested": "object"}]. Unions are now rendered +// inline as PHP 8.0 union type declarations, with runtime type dispatch in +// the from/to/validate converters. +import { describe, expect, test } from "vitest"; + +import { + InputData, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function phpForJSONSamples(samples: string[]): Promise { + const jsonInput = jsonInputForTargetLanguage("php"); + await jsonInput.addSource({ name: "TopLevel", samples }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ inputData, lang: "php" }); + return result.lines.join("\n"); +} + +describe("PHP union support", () => { + test("heterogeneous arrays render with runtime dispatch", async () => { + const php = await phpForJSONSamples([ + '{"mixed": [1, "two", true, null, {"nested": "object"}]}', + ]); + // Each member gets a runtime type check in the converters. + expect(php).toMatch(/is_int\(\$value\)/); + expect(php).toMatch(/is_string\(\$value\)/); + expect(php).toMatch(/is_bool\(\$value\)/); + expect(php).toMatch(/is_null\(\$value\)/); + expect(php).toMatch(/\$value instanceof \w/); + // Unmatched values fail loudly instead of being silently dropped. + expect(php).toMatch(/Cannot deserialize union value/); + }); + + test("a union property gets a PHP 8 union type declaration", async () => { + const php = await phpForJSONSamples([ + '{"v": 1}', + '{"v": "s"}', + '{"v": {"x": 1}}', + ]); + expect(php).toMatch(/private V\|int\|string \$v;/); + expect(php).toMatch(/stdClass\|int\|string \$value/); + }); + + test("nullable unions keep the ?-prefixed hint", async () => { + const php = await phpForJSONSamples(['{"a": "x"}', '{"a": null}']); + expect(php).toMatch(/private \?string \$a;/); + }); + + test("a double member accepts PHP integers", async () => { + // PHP has no union of both number types; 1 and 2.5 unify to + // double, whose runtime check must still match int values. + const php = await phpForJSONSamples(['{"n": [1, 2.5, "s"]}']); + expect(php).toMatch(/is_float\(\$value\) \|\| is_int\(\$value\)/); + }); + + test("reserved words are not used as class names", async () => { + // "mixed" is a reserved type name in PHP 8; `class Mixed` would + // fail to compile. + const php = await phpForJSONSamples(['{"mixed": {"a": 1}}']); + expect(php).not.toMatch(/class Mixed\b/); + expect(php).toMatch(/class MixedClass\b/); + }); +}); From b69df720a14ae4fa78f7669f6445009442123d82 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 12:24:07 -0400 Subject: [PATCH 2/6] feat(options): accept just-types in every language that spells it as an enum Most languages spell "generate plain types without (de)serialization helpers" as a `just-types` boolean option, but C# spelled it `--features just-types` and Kotlin, Scala 3, and Smithy4s `--framework just-types`, so the common `--just-types` flag was rejected for them with an option-parsing error. Those four languages now also accept `just-types` (secondary option). When it's set, makeRenderer forces the corresponding enum option to its just-types value before resolving options, so the boolean wins over a conflicting explicit enum value. The enum spellings keep working unchanged. The renderer-options type test that used kotlin + just-types as its "another language's option" compile error now uses rust, which really has no such option. Co-Authored-By: Claude Fable 5 --- .../src/language/CSharp/language.ts | 15 +++++ .../src/language/Kotlin/language.ts | 17 +++++ .../src/language/Scala3/language.ts | 17 +++++ .../src/language/Smithy4s/language.ts | 9 +++ test/unit/just-types-option.test.ts | 66 +++++++++++++++++++ test/unit/renderer-options.test-d.ts | 4 +- 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 test/unit/just-types-option.test.ts diff --git a/packages/quicktype-core/src/language/CSharp/language.ts b/packages/quicktype-core/src/language/CSharp/language.ts index d07785dd4a..754c325e95 100644 --- a/packages/quicktype-core/src/language/CSharp/language.ts +++ b/packages/quicktype-core/src/language/CSharp/language.ts @@ -117,6 +117,14 @@ export const cSharpOptions = { } as const, "complete", ), + // The boolean spelling of `--features just-types`, so that + // `--just-types` works for C# like it does for most other languages. + justTypes: new BooleanOption( + "just-types", + "Plain types only (same as features=just-types)", + false, + "secondary", + ), baseclass: new EnumOption( "base-class", "Base class", @@ -190,6 +198,13 @@ export class CSharpTargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { + if (cSharpOptions.justTypes.getValue(untypedOptionValues)) { + untypedOptionValues = { + ...untypedOptionValues, + features: "just-types", + } as RendererOptions; + } + const options = getOptionValues(cSharpOptions, untypedOptionValues); switch (options.framework) { diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index c434958149..854d232432 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -1,6 +1,7 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { RenderContext } from "../../Renderer.js"; import { + BooleanOption, EnumOption, StringOption, getOptionValues, @@ -27,6 +28,15 @@ export const kotlinOptions = { } as const, "klaxon", ), + // The boolean spelling of `--framework just-types`, so that + // `--just-types` works for Kotlin like it does for most other + // languages. + justTypes: new BooleanOption( + "just-types", + "Plain types only (same as framework=just-types)", + false, + "secondary", + ), acronymStyle: acronymOption(AcronymStyleOptions.Pascal), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; @@ -60,6 +70,13 @@ export class KotlinTargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { + if (kotlinOptions.justTypes.getValue(untypedOptionValues)) { + untypedOptionValues = { + ...untypedOptionValues, + framework: "just-types", + } as RendererOptions; + } + const options = getOptionValues(kotlinOptions, untypedOptionValues); switch (options.framework) { diff --git a/packages/quicktype-core/src/language/Scala3/language.ts b/packages/quicktype-core/src/language/Scala3/language.ts index 3f98d37177..aba0075e4c 100644 --- a/packages/quicktype-core/src/language/Scala3/language.ts +++ b/packages/quicktype-core/src/language/Scala3/language.ts @@ -1,6 +1,7 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { RenderContext } from "../../Renderer.js"; import { + BooleanOption, EnumOption, StringOption, getOptionValues, @@ -24,6 +25,15 @@ export const scala3Options = { } as const, "just-types", ), + // The boolean spelling of `--framework just-types`, so that + // `--just-types` works for Scala like it does for most other + // languages. + justTypes: new BooleanOption( + "just-types", + "Plain types only (same as framework=just-types)", + false, + "secondary", + ), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; @@ -56,6 +66,13 @@ export class Scala3TargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { + if (scala3Options.justTypes.getValue(untypedOptionValues)) { + untypedOptionValues = { + ...untypedOptionValues, + framework: "just-types", + } as RendererOptions; + } + const options = getOptionValues(scala3Options, untypedOptionValues); switch (options.framework) { diff --git a/packages/quicktype-core/src/language/Smithy4s/language.ts b/packages/quicktype-core/src/language/Smithy4s/language.ts index 1213c92fd0..114362f9b9 100644 --- a/packages/quicktype-core/src/language/Smithy4s/language.ts +++ b/packages/quicktype-core/src/language/Smithy4s/language.ts @@ -1,6 +1,7 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { RenderContext } from "../../Renderer.js"; import { + BooleanOption, EnumOption, StringOption, getOptionValues, @@ -23,6 +24,14 @@ export const smithyOptions = { { "just-types": Framework.None } as const, "just-types", ), + // Smithy only ever generates plain types; the flag is accepted for + // consistency with the other languages. + justTypes: new BooleanOption( + "just-types", + "Plain types only (the only mode Smithy supports)", + false, + "secondary", + ), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts new file mode 100644 index 0000000000..b417d8fb90 --- /dev/null +++ b/test/unit/just-types-option.test.ts @@ -0,0 +1,66 @@ +// Most languages spell "generate plain types without (de)serialization +// helpers" as a `just-types` boolean option, but C# spelled it +// `features=just-types` and Kotlin/Scala/Smithy `framework=just-types`, so +// the CLI's `--just-types` flag was rejected for those languages. They now +// also accept `just-types`, which forces the corresponding enum option. +import { describe, expect, test } from "vitest"; + +import { + InputData, + type LanguageName, + type RendererOptions, + jsonInputForTargetLanguage, + quicktype, +} from "quicktype-core"; + +async function linesFor( + lang: LanguageName, + rendererOptions: RendererOptions, +): Promise { + const jsonInput = jsonInputForTargetLanguage(lang); + await jsonInput.addSource({ + name: "Person", + samples: ['{"name": "Alice", "age": 37}'], + }); + const inputData = new InputData(); + inputData.addInput(jsonInput); + const result = await quicktype({ inputData, lang, rendererOptions }); + return result.lines.join("\n"); +} + +describe("just-types is accepted by every enum-spelled language", () => { + test("C#: just-types matches features=just-types", async () => { + const viaBoolean = await linesFor("csharp", { "just-types": true }); + const viaEnum = await linesFor("csharp", { features: "just-types" }); + expect(viaBoolean).toEqual(viaEnum); + expect(viaBoolean).not.toContain("JsonConverter"); + }); + + test("Kotlin: just-types matches framework=just-types", async () => { + const viaBoolean = await linesFor("kotlin", { "just-types": true }); + const viaEnum = await linesFor("kotlin", { framework: "just-types" }); + expect(viaBoolean).toEqual(viaEnum); + expect(viaBoolean).not.toContain("Klaxon"); + }); + + test("Scala: just-types matches framework=just-types", async () => { + const viaBoolean = await linesFor("scala3", { "just-types": true }); + const viaEnum = await linesFor("scala3", { framework: "just-types" }); + expect(viaBoolean).toEqual(viaEnum); + }); + + test("Smithy: just-types is accepted", async () => { + const viaBoolean = await linesFor("smithy4a", { "just-types": true }); + const viaDefault = await linesFor("smithy4a", {}); + expect(viaBoolean).toEqual(viaDefault); + }); + + test("the boolean wins over a conflicting enum value", async () => { + const viaBoolean = await linesFor("kotlin", { + "just-types": true, + framework: "jackson", + }); + const plain = await linesFor("kotlin", { framework: "just-types" }); + expect(viaBoolean).toEqual(plain); + }); +}); diff --git a/test/unit/renderer-options.test-d.ts b/test/unit/renderer-options.test-d.ts index f86fcde9c3..33fd010369 100644 --- a/test/unit/renderer-options.test-d.ts +++ b/test/unit/renderer-options.test-d.ts @@ -52,8 +52,8 @@ describe("rendererOptions typing", () => { test("rejects another language's option name", () => { void quicktype({ inputData, - lang: "kotlin", - // @ts-expect-error `just-types` is a C#/TypeScript spelling; Kotlin has no such option + lang: "rust", + // @ts-expect-error Rust has no `just-types` option rendererOptions: { "just-types": "true" }, }); }); From 23511bac63e7d0f7a3228b46d33a0805c2f060ef Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 14:47:50 -0400 Subject: [PATCH 3/6] feat(options)!: make --just-types the only spelling, everywhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: the enum spellings of "plain types" are gone. C# loses `--features just-types` and `--features just-types-and-namespace` (their value maps were identical); Kotlin and Scala 3 lose `--framework just-types`; Smithy4s loses its single-value `--framework` option entirely. Every language now spells it the way the other 15 always have: the boolean `--just-types`, which wins over a conflicting explicit `--features`/`--framework`. Scala 3's default changes with this: a bare `quicktype -l scala3` now generates circe (de)serialization instead of plain case classes — plain types now require asking for `--just-types`, like in every other language. The VS Code extension's per-language special-casing collapses into a single check for whether the target language has a `just-types` option (fixing a bug where its "just types" commands silently did nothing for languages other than C# and Kotlin), and the Smithy4s test fixture migrates to the boolean spelling. Co-Authored-By: Claude Fable 5 --- .../CSharp/NewtonSoftCSharpRenderer.ts | 6 +- .../CSharp/SystemTextJsonCSharpRenderer.ts | 6 +- .../src/language/CSharp/language.ts | 26 +--- .../src/language/Kotlin/language.ts | 25 +--- .../src/language/Scala3/language.ts | 27 +--- .../src/language/Smithy4s/language.ts | 37 ++--- packages/quicktype-vscode/src/extension.ts | 19 +-- test/languages.ts | 2 +- test/unit/just-types-option.test.ts | 128 ++++++++++++++---- 9 files changed, 137 insertions(+), 139 deletions(-) diff --git a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts index 0df4b758f2..c69107ebd9 100644 --- a/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/NewtonSoftCSharpRenderer.ts @@ -68,8 +68,10 @@ export class NewtonsoftCSharpRenderer extends CSharpRenderer { private readonly _options: OptionValues, ) { super(targetLanguage, renderContext, _options); - this._needHelpers = _options.features.helpers; - this._needAttributes = _options.features.attributes; + // `--just-types` wins over whatever `--features` says. + this._needHelpers = _options.features.helpers && !_options.justTypes; + this._needAttributes = + _options.features.attributes && !_options.justTypes; this._needNamespaces = _options.features.namespaces; } diff --git a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts index 8eb22b14b6..34204577b4 100644 --- a/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts +++ b/packages/quicktype-core/src/language/CSharp/SystemTextJsonCSharpRenderer.ts @@ -69,8 +69,10 @@ export class SystemTextJsonCSharpRenderer extends CSharpRenderer { >, ) { super(targetLanguage, renderContext, _options); - this._needHelpers = _options.features.helpers; - this._needAttributes = _options.features.attributes; + // `--just-types` wins over whatever `--features` says. + this._needHelpers = _options.features.helpers && !_options.justTypes; + this._needAttributes = + _options.features.attributes && !_options.justTypes; this._needNamespaces = _options.features.namespaces; } diff --git a/packages/quicktype-core/src/language/CSharp/language.ts b/packages/quicktype-core/src/language/CSharp/language.ts index 754c325e95..0b0c7235f5 100644 --- a/packages/quicktype-core/src/language/CSharp/language.ts +++ b/packages/quicktype-core/src/language/CSharp/language.ts @@ -104,27 +104,10 @@ export const cSharpOptions = { helpers: false, attributes: true, }, - "just-types-and-namespace": { - namespaces: true, - helpers: false, - attributes: false, - }, - "just-types": { - namespaces: true, - helpers: false, - attributes: false, - }, } as const, "complete", ), - // The boolean spelling of `--features just-types`, so that - // `--just-types` works for C# like it does for most other languages. - justTypes: new BooleanOption( - "just-types", - "Plain types only (same as features=just-types)", - false, - "secondary", - ), + justTypes: new BooleanOption("just-types", "Plain types only", false), baseclass: new EnumOption( "base-class", "Base class", @@ -198,13 +181,6 @@ export class CSharpTargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { - if (cSharpOptions.justTypes.getValue(untypedOptionValues)) { - untypedOptionValues = { - ...untypedOptionValues, - features: "just-types", - } as RendererOptions; - } - const options = getOptionValues(cSharpOptions, untypedOptionValues); switch (options.framework) { diff --git a/packages/quicktype-core/src/language/Kotlin/language.ts b/packages/quicktype-core/src/language/Kotlin/language.ts index 854d232432..f806d423f9 100644 --- a/packages/quicktype-core/src/language/Kotlin/language.ts +++ b/packages/quicktype-core/src/language/Kotlin/language.ts @@ -17,26 +17,17 @@ import { KotlinRenderer } from "./KotlinRenderer.js"; import { KotlinXRenderer } from "./KotlinXRenderer.js"; export const kotlinOptions = { + justTypes: new BooleanOption("just-types", "Plain types only", false), framework: new EnumOption( "framework", "Serialization framework", { - "just-types": "None", jackson: "Jackson", klaxon: "Klaxon", kotlinx: "KotlinX", } as const, "klaxon", ), - // The boolean spelling of `--framework just-types`, so that - // `--just-types` works for Kotlin like it does for most other - // languages. - justTypes: new BooleanOption( - "just-types", - "Plain types only (same as framework=just-types)", - false, - "secondary", - ), acronymStyle: acronymOption(AcronymStyleOptions.Pascal), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; @@ -70,18 +61,14 @@ export class KotlinTargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { - if (kotlinOptions.justTypes.getValue(untypedOptionValues)) { - untypedOptionValues = { - ...untypedOptionValues, - framework: "just-types", - } as RendererOptions; - } - const options = getOptionValues(kotlinOptions, untypedOptionValues); + // `--just-types` wins over whatever `--framework` says. + if (options.justTypes) { + return new KotlinRenderer(this, renderContext, options); + } + switch (options.framework) { - case "None": - return new KotlinRenderer(this, renderContext, options); case "Jackson": return new KotlinJacksonRenderer(this, renderContext, options); case "Klaxon": diff --git a/packages/quicktype-core/src/language/Scala3/language.ts b/packages/quicktype-core/src/language/Scala3/language.ts index aba0075e4c..f1e8d10630 100644 --- a/packages/quicktype-core/src/language/Scala3/language.ts +++ b/packages/quicktype-core/src/language/Scala3/language.ts @@ -15,24 +15,15 @@ import { Scala3Renderer } from "./Scala3Renderer.js"; import { UpickleRenderer } from "./UpickleRenderer.js"; export const scala3Options = { + justTypes: new BooleanOption("just-types", "Plain types only", false), framework: new EnumOption( "framework", "Serialization framework", { - "just-types": "None", circe: "Circe", upickle: "Upickle", } as const, - "just-types", - ), - // The boolean spelling of `--framework just-types`, so that - // `--just-types` works for Scala like it does for most other - // languages. - justTypes: new BooleanOption( - "just-types", - "Plain types only (same as framework=just-types)", - false, - "secondary", + "circe", ), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; @@ -66,18 +57,14 @@ export class Scala3TargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { - if (scala3Options.justTypes.getValue(untypedOptionValues)) { - untypedOptionValues = { - ...untypedOptionValues, - framework: "just-types", - } as RendererOptions; - } - const options = getOptionValues(scala3Options, untypedOptionValues); + // `--just-types` wins over whatever `--framework` says. + if (options.justTypes) { + return new Scala3Renderer(this, renderContext, options); + } + switch (options.framework) { - case "None": - return new Scala3Renderer(this, renderContext, options); case "Upickle": return new UpickleRenderer(this, renderContext, options); case "Circe": diff --git a/packages/quicktype-core/src/language/Smithy4s/language.ts b/packages/quicktype-core/src/language/Smithy4s/language.ts index 114362f9b9..5376a3a6a4 100644 --- a/packages/quicktype-core/src/language/Smithy4s/language.ts +++ b/packages/quicktype-core/src/language/Smithy4s/language.ts @@ -2,36 +2,18 @@ import type { ConvenienceRenderer } from "../../ConvenienceRenderer.js"; import type { RenderContext } from "../../Renderer.js"; import { BooleanOption, - EnumOption, StringOption, getOptionValues, } from "../../RendererOptions/index.js"; -import { assertNever } from "../../support/Support.js"; import { TargetLanguage } from "../../TargetLanguage.js"; import type { LanguageName, RendererOptions } from "../../types.js"; import { Smithy4sRenderer } from "./Smithy4sRenderer.js"; -export enum Framework { - None = "None", -} - export const smithyOptions = { - // FIXME: why does this exist - framework: new EnumOption( - "framework", - "Serialization framework", - { "just-types": Framework.None } as const, - "just-types", - ), - // Smithy only ever generates plain types; the flag is accepted for - // consistency with the other languages. - justTypes: new BooleanOption( - "just-types", - "Plain types only (the only mode Smithy supports)", - false, - "secondary", - ), + // Plain types is the only mode Smithy supports; the flag is accepted + // for consistency with the other languages. + justTypes: new BooleanOption("just-types", "Plain types only", false), packageName: new StringOption("package", "Package", "PACKAGE", "quicktype"), }; @@ -64,13 +46,10 @@ export class SmithyTargetLanguage extends TargetLanguage< renderContext: RenderContext, untypedOptionValues: RendererOptions, ): ConvenienceRenderer { - const options = getOptionValues(smithyOptions, untypedOptionValues); - - switch (options.framework) { - case Framework.None: - return new Smithy4sRenderer(this, renderContext, options); - default: - return assertNever(options.framework); - } + return new Smithy4sRenderer( + this, + renderContext, + getOptionValues(smithyOptions, untypedOptionValues), + ); } } diff --git a/packages/quicktype-vscode/src/extension.ts b/packages/quicktype-vscode/src/extension.ts index 08f5e011d9..506efb9d97 100644 --- a/packages/quicktype-vscode/src/extension.ts +++ b/packages/quicktype-vscode/src/extension.ts @@ -106,19 +106,12 @@ async function runQuicktype( vscode.workspace.getConfiguration(configurationSection); const justTypes = forceJustTypes || configuration.justTypes; - const rendererOptions: RendererOptions = {}; - if (justTypes) { - // FIXME: The target language should have a property to return these options. - if (lang.name === "csharp") { - (rendererOptions as RendererOptions<"csharp">).features = - "just-types"; - } else if (lang.name === "kotlin") { - (rendererOptions as RendererOptions<"kotlin">).framework = - "just-types"; - } else if ("just-types" in rendererOptions) { - rendererOptions["just-types"] = "true"; - } - } + // Not every language has a `just-types` option — JSON Schema, for + // example, has no serialization helpers to leave out. + const rendererOptions: RendererOptions = + justTypes && lang.optionDefinitions.some((o) => o.name === "just-types") + ? ({ "just-types": "true" } as RendererOptions) + : {}; const inputData = new InputData(); switch (kind) { diff --git a/test/languages.ts b/test/languages.ts index b064f74c8d..a03ef19e2c 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -1083,7 +1083,7 @@ I havea no idea how to encode these tests correctly. ], skipSchema: [], skipMiscJSON: false, - rendererOptions: { framework: "just-types" }, + rendererOptions: { "just-types": "true" }, quickTestRendererOptions: [], sourceFiles: ["src/language/Smithy4s/index.ts"], }; diff --git a/test/unit/just-types-option.test.ts b/test/unit/just-types-option.test.ts index b417d8fb90..8ae56e317a 100644 --- a/test/unit/just-types-option.test.ts +++ b/test/unit/just-types-option.test.ts @@ -1,8 +1,8 @@ -// Most languages spell "generate plain types without (de)serialization -// helpers" as a `just-types` boolean option, but C# spelled it -// `features=just-types` and Kotlin/Scala/Smithy `framework=just-types`, so -// the CLI's `--just-types` flag was rejected for those languages. They now -// also accept `just-types`, which forces the corresponding enum option. +// Every quicktype language spells "generate plain types without +// (de)serialization helpers" as the boolean `just-types` option. C#'s +// `features=just-types` / `features=just-types-and-namespace` and Kotlin's, +// Scala 3's, and Smithy4s's `framework=just-types` are gone; the boolean +// wins over a conflicting explicit enum value. import { describe, expect, test } from "vitest"; import { @@ -15,7 +15,7 @@ import { async function linesFor( lang: LanguageName, - rendererOptions: RendererOptions, + rendererOptions: RendererOptions = {}, ): Promise { const jsonInput = jsonInputForTargetLanguage(lang); await jsonInput.addSource({ @@ -28,39 +28,111 @@ async function linesFor( return result.lines.join("\n"); } -describe("just-types is accepted by every enum-spelled language", () => { - test("C#: just-types matches features=just-types", async () => { - const viaBoolean = await linesFor("csharp", { "just-types": true }); - const viaEnum = await linesFor("csharp", { features: "just-types" }); - expect(viaBoolean).toEqual(viaEnum); - expect(viaBoolean).not.toContain("JsonConverter"); +describe("just-types generates plain types in every language", () => { + test("C#: no attributes, no helpers, but a namespace", async () => { + const output = await linesFor("csharp", { "just-types": true }); + expect(output).toContain("namespace QuickType"); + expect(output).toContain("public partial class Person"); + expect(output).not.toContain("JsonConverter"); + expect(output).not.toContain("JsonProperty"); }); - test("Kotlin: just-types matches framework=just-types", async () => { - const viaBoolean = await linesFor("kotlin", { "just-types": true }); - const viaEnum = await linesFor("kotlin", { framework: "just-types" }); - expect(viaBoolean).toEqual(viaEnum); - expect(viaBoolean).not.toContain("Klaxon"); + test("Kotlin: plain data classes, no Klaxon", async () => { + const output = await linesFor("kotlin", { "just-types": true }); + expect(output).toContain("data class Person"); + expect(output).not.toContain("Klaxon"); }); - test("Scala: just-types matches framework=just-types", async () => { - const viaBoolean = await linesFor("scala3", { "just-types": true }); - const viaEnum = await linesFor("scala3", { framework: "just-types" }); - expect(viaBoolean).toEqual(viaEnum); + test("Scala 3: plain case classes, no circe", async () => { + const output = await linesFor("scala3", { "just-types": true }); + expect(output).toContain("case class Person"); + expect(output).not.toContain("circe"); }); - test("Smithy: just-types is accepted", async () => { + test("Smithy: accepted (plain types is the only mode)", async () => { const viaBoolean = await linesFor("smithy4a", { "just-types": true }); - const viaDefault = await linesFor("smithy4a", {}); - expect(viaBoolean).toEqual(viaDefault); + expect(viaBoolean).toContain("structure Person"); + expect(viaBoolean).toEqual(await linesFor("smithy4a")); }); +}); + +describe("the removed enum spellings are errors", () => { + // The old spellings aren't valid `RendererOptions` anymore, which is + // the point: they must also fail for API callers who evade the types. + test("C#: features=just-types", async () => { + const options = { features: "just-types" } as RendererOptions; + await expect(linesFor("csharp", options)).rejects.toThrow( + "Unknown value just-types for option features", + ); + }); + + test("C#: features=just-types-and-namespace", async () => { + const options = { + features: "just-types-and-namespace", + } as RendererOptions; + await expect(linesFor("csharp", options)).rejects.toThrow( + "Unknown value just-types-and-namespace for option features", + ); + }); + + test("Kotlin: framework=just-types", async () => { + const options = { framework: "just-types" } as RendererOptions; + await expect(linesFor("kotlin", options)).rejects.toThrow( + "Unknown value just-types for option framework", + ); + }); + + test("Scala 3: framework=just-types", async () => { + const options = { framework: "just-types" } as RendererOptions; + await expect(linesFor("scala3", options)).rejects.toThrow( + "Unknown value just-types for option framework", + ); + }); +}); - test("the boolean wins over a conflicting enum value", async () => { - const viaBoolean = await linesFor("kotlin", { +describe("just-types wins over a conflicting enum option", () => { + test("C#: just-types beats features=attributes-only", async () => { + const output = await linesFor("csharp", { + "just-types": true, + features: "attributes-only", + }); + expect(output).toEqual( + await linesFor("csharp", { "just-types": true }), + ); + expect(output).not.toContain("JsonProperty"); + }); + + test("Kotlin: just-types beats framework=jackson", async () => { + const output = await linesFor("kotlin", { "just-types": true, framework: "jackson", }); - const plain = await linesFor("kotlin", { framework: "just-types" }); - expect(viaBoolean).toEqual(plain); + expect(output).toEqual( + await linesFor("kotlin", { "just-types": true }), + ); + expect(output).not.toContain("jackson"); + }); + + test("Scala 3: just-types beats framework=upickle", async () => { + const output = await linesFor("scala3", { + "just-types": true, + framework: "upickle", + }); + expect(output).toEqual( + await linesFor("scala3", { "just-types": true }), + ); + expect(output).not.toContain("upickle"); + }); +}); + +describe("framework defaults", () => { + test("Scala 3 defaults to circe", async () => { + const output = await linesFor("scala3"); + expect(output).toContain("io.circe"); + }); + + test("Kotlin defaults to Klaxon", async () => { + const output = await linesFor("kotlin"); + expect(output).toContain("com.beust.klaxon"); }); }); From 48c2ca118af46c7e5091af419b4b6437540d44ed Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:13:38 -0400 Subject: [PATCH 4/6] fix(php): renderer bugs uncovered by enabling fixture tests Running the union-heavy JSON inputs and the JSON Schema fixture against the PHP renderer for the first time exposed several bugs in generated code: - `any`- and `null`-typed properties were declared as `Object` (and the converter parameters as the nonexistent types `any`/`null`), which fatals as soon as a null or scalar flows through. They are now `mixed`, matching the PHP 8.0 baseline the inline union declarations already require. - The `any` validation called `defined()`, which tests whether a *constant* of the given name exists and throws for non-string arguments. Any value is a valid `any`, so no check is emitted. - Double validation used a bare `is_float`, rejecting whole numbers, which json_decode hands over as ints. It now accepts ints, like the union member checks already did. - Nullable DateTime/UUID properties lost their `?` in type declarations (`optionalize` was not applied), so `fromX` fataled returning null. - The nullable branch of the `from` converters emitted a hardcoded `return null;`, which is wrong when the conversion is nested inside a map's foreach; it now uses the caller's lhs like the `to` side does. - Nested array samples closed with `);` in expression position, and map samples emitted statements in expression position; array closings now respect the caller's suffix and map samples are immediately-invoked closures. - Classes without properties generated a bodyless non-void `validate(): bool`, a TypeError when called; it now returns true. - A property named "this" produced an illegal `$this` constructor parameter; "this" is now a forbidden property name. - A union-member check for an enum on the JSON side only tested `is_string`, shadowing plain-string members (e.g. a UUID sharing a union with an enum); it now checks membership in the enum's values. Co-Authored-By: Claude Fable 5 --- .../src/language/Php/PhpRenderer.ts | 113 ++++++++++++++---- 1 file changed, 90 insertions(+), 23 deletions(-) diff --git a/packages/quicktype-core/src/language/Php/PhpRenderer.ts b/packages/quicktype-core/src/language/Php/PhpRenderer.ts index c8682f9199..85d4ea4ee0 100644 --- a/packages/quicktype-core/src/language/Php/PhpRenderer.ts +++ b/packages/quicktype-core/src/language/Php/PhpRenderer.ts @@ -74,7 +74,9 @@ export class PhpRenderer extends ConvenienceRenderer { _c: ClassType, _className: Name, ): ForbiddenWordsInfo { - return { names: [], includeGlobalForbidden: true }; + // `$this` is the only variable name PHP reserves; a property named + // "this" would produce an illegal `$this` constructor parameter. + return { names: ["this"], includeGlobalForbidden: true }; } protected makeNamedTypeNamer(): Namer { @@ -364,10 +366,30 @@ export class PhpRenderer extends ConvenienceRenderer { return jsonSide ? ["is_string(", ...expr, ")"] : [...expr, " instanceof DateTime"]; - case "enum": - return jsonSide - ? ["is_string(", ...expr, ")"] - : [...expr, " instanceof ", this.nameForNamedType(t)]; + case "enum": { + if (!jsonSide) { + return [...expr, " instanceof ", this.nameForNamedType(t)]; + } + + // On the JSON side an enum value is a string, but a plain + // string member may share the union, so check membership in + // the enum's values rather than just `is_string`. + const check: Sourcelike[] = [ + "is_string(", + ...expr, + ") && in_array(", + ...expr, + ", [", + ]; + let first = true; + for (const jsonName of (t as EnumType).cases) { + if (!first) check.push(", "); + check.push("'", stringEscape(jsonName), "'"); + first = false; + } + check.push("], true)"); + return check; + } case "class": return jsonSide ? ["is_object(", ...expr, ")"] @@ -441,9 +463,9 @@ export class PhpRenderer extends ConvenienceRenderer { return matchType( t, (_anyType) => - maybeAnnotated(isOptional, anyTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, anyTypeIssueAnnotation, "mixed"), (_nullType) => - maybeAnnotated(isOptional, nullTypeIssueAnnotation, "Object"), + maybeAnnotated(isOptional, nullTypeIssueAnnotation, "mixed"), (_boolType) => optionalize("bool"), (_integerType) => optionalize("int"), (_doubleType) => optionalize("float"), @@ -468,10 +490,10 @@ export class PhpRenderer extends ConvenienceRenderer { } if (transformedStringType.kind === "date-time") { - return "DateTime"; + return optionalize("DateTime"); } - return "string"; + return optionalize("string"); }, ); } @@ -530,8 +552,8 @@ export class PhpRenderer extends ConvenienceRenderer { protected phpConvertType(className: Name, t: Type): Sourcelike { return matchType( t, - (_anyType) => "any", - (_nullType) => "null", + (_anyType) => "mixed", + (_nullType) => "mixed", (_boolType) => "bool", (_integerType) => "int", (_doubleType) => "float", @@ -743,7 +765,7 @@ export class PhpRenderer extends ConvenienceRenderer { this.phpFromObjConvert(className, nullable, lhs, args), ); this.emitLine("} else {"); - this.indent(() => this.emitLine("return null;")); + this.indent(() => this.emitLine(...lhs, " null;")); this.emitLine("}"); return; } @@ -883,7 +905,7 @@ export class PhpRenderer extends ConvenienceRenderer { "", ); }); - this.emitLine("); /* ", `${idx}`, ":", args, "*/"); + this.emitLine(")", suffix, " /* ", `${idx}`, ":", args, "*/"); }, (classType) => this.emitLine( @@ -898,16 +920,30 @@ export class PhpRenderer extends ConvenienceRenderer { "*/", ), (mapType) => { - this.emitLine("$out = new stdClass();"); - this.phpSampleConvert( - className, - mapType.values, - ["$out->{'", className, "'} = "], + // An immediately-invoked closure, so the sample is an + // expression and can nest inside arrays and other maps. + this.emitLine(...lhs, " (function () {"); + this.indent(() => { + this.emitLine("$out = new stdClass();"); + this.phpSampleConvert( + className, + mapType.values, + ["$out->{'", className, "'} = "], + args, + idx, + ";", + ); + this.emitLine("return $out;"); + }); + this.emitLine( + "})()", + suffix, + " /* ", + `${idx}`, + ":", args, - idx, - ";", + "*/", ); - this.emitLine("return $out;"); }, (enumType) => this.emitLine( @@ -995,11 +1031,36 @@ export class PhpRenderer extends ConvenienceRenderer { matchType( t, - (_anyType) => is("defined"), + (_anyType) => { + // Every value is a valid `any`; there is nothing to check. + // (This used to call `defined()`, which tests whether a + // *constant* of the given name exists and throws for + // non-string arguments.) + }, (_nullType) => is("is_null"), (_boolType) => is("is_bool"), (_integerType) => is("is_integer"), - (_doubleType) => is("is_float"), + (_doubleType) => { + // PHP integers are acceptable wherever floats are, and + // json_decode gives an int for a whole JSON number. + this.emitBlock( + [ + "if (!is_float(", + scopeAttrName, + ") && !is_int(", + scopeAttrName, + "))", + ], + () => + this.emitLine( + 'throw new Exception("Attribute Error:', + className, + "::", + attrName, + '");', + ), + ); + }, (_stringType) => is("is_string"), (arrayType) => { is("is_array"); @@ -1462,6 +1523,12 @@ export class PhpRenderer extends ConvenienceRenderer { p = "|| "; }, ); + if (lines.length === 0) { + // A class without properties has nothing to check. + this.emitLine("return true;"); + return; + } + lines.forEach((line, jdx) => { this.emitLine( ...line, From 8e845c900a95be01091978cb9ab3ce18490a538d Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:13:53 -0400 Subject: [PATCH 5/6] test(php): exercise non-nullable unions through the fixture system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP fixture previously ran only a whitelist of easy JSON samples and no schema fixture at all, so the new union support was covered by unit tests alone. quicktype's primary testing method is the end-to-end JSON / JSON Schema fixture tests, so: - Enable the union-heavy JSON inputs for PHP: unions.json, union-constructor-clash.json, combinations1-4.json, nst-test-suite.json, kitchen-sink.json, list.json and bug427.json. - Add test/inputs/json/priority/php-mixed-union.json, the motivating repro: a heterogeneous array (int, string, bool, null and an object) under a property named "mixed", which is a PHP reserved word, so it exercises the reserved-word class renaming and the union runtime dispatch together. The input is skipped for the languages that already skip unions.json (Ruby, Scala 3, Smithy4s, Kotlin and Kotlin Jackson), since it is a subset of that input's shapes. - Register the JSON Schema fixture for PHP and run it in CI as schema-php. 61 of the 65 schemas run; the four skips are keyword-unions.schema (PHP class names are case-insensitive but the namer dedups case-sensitively — the same reason Java and Python skip it), recursive-union-flattening.schema (a top-level union produces no named TopLevel class now that unions are inlined), top-level-enum.schema (generated top-level enum code is incompatible with the driver, as for C# and Ruby) and union.schema (the driver does not support top-level arrays). Not enabled: optional-union.json and the identifiers/keywords inputs. Top-level arrays are unsupported by the PHP driver, identifiers.json needs comment/string escaping of raw JSON names, and keywords.json hits the case-insensitive class-name collision above — all pre-existing limitations independent of union support. Co-Authored-By: Claude Fable 5 --- .github/workflows/test-pr.yaml | 2 +- test/fixtures.ts | 1 + .../inputs/json/priority/php-mixed-union.json | 3 ++ test/languages.ts | 35 ++++++++++++++++++- 4 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 test/inputs/json/priority/php-mixed-union.json diff --git a/.github/workflows/test-pr.yaml b/.github/workflows/test-pr.yaml index 41750129c2..e1e30b95a5 100644 --- a/.github/workflows/test-pr.yaml +++ b/.github/workflows/test-pr.yaml @@ -50,7 +50,7 @@ jobs: # - swift,schema-swift # pgp issue - javascript-prop-types - ruby - - php + - php,schema-php - scala3,schema-scala3 - elixir,schema-elixir,graphql-elixir - comment-injection-treesitter,comment-injection-typescript,comment-injection-typescript-zod,comment-injection-typescript-effect-schema diff --git a/test/fixtures.ts b/test/fixtures.ts index 33f9dd1c29..d9f78eaea2 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1589,6 +1589,7 @@ export const allFixtures: Fixture[] = [ new JSONSchemaFixture(languages.RustLanguage), new JSONSchemaFixture(languages.RubyLanguage), new JSONSchemaFixture(languages.PythonLanguage), + new JSONSchemaFixture(languages.PHPLanguage), new JSONSchemaFixture(languages.ElmLanguage), new JSONSchemaFixture(languages.SwiftLanguage), new JSONSchemaFixture(languages.TypeScriptLanguage), diff --git a/test/inputs/json/priority/php-mixed-union.json b/test/inputs/json/priority/php-mixed-union.json new file mode 100644 index 0000000000..ed7ec4427b --- /dev/null +++ b/test/inputs/json/priority/php-mixed-union.json @@ -0,0 +1,3 @@ +{ + "mixed": [1, "two", true, null, {"nested": "object"}] +} diff --git a/test/languages.ts b/test/languages.ts index b064f74c8d..73da3248fb 100644 --- a/test/languages.ts +++ b/test/languages.ts @@ -427,6 +427,7 @@ export const RubyLanguage: Language = { "optional-union.json", "union-constructor-clash.json", "unions.json", + "php-mixed-union.json", "nbl-stats.json", "kitchen-sink.json", ], @@ -995,6 +996,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Scala3 has the same prelude-shadowing bug that this input @@ -1079,6 +1081,7 @@ I havea no idea how to encode these tests correctly. "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", ], skipSchema: [], @@ -1131,6 +1134,7 @@ export const KotlinLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1218,6 +1222,7 @@ export const KotlinJacksonLanguage: Language = { "combinations3.json", "combinations4.json", "unions.json", + "php-mixed-union.json", "nst-test-suite.json", // Klaxon does not support top-level primitives "no-classes.json", @@ -1474,9 +1479,37 @@ export const PHPLanguage: Language = { "uuids.json", "nested-objects.json", "bug2663.json", + // Union-heavy inputs: PHP renders non-nullable unions as inline + // PHP 8.0 union type declarations with runtime dispatch. + "unions.json", + "union-constructor-clash.json", + "combinations1.json", + "combinations2.json", + "combinations3.json", + "combinations4.json", + "nst-test-suite.json", + "kitchen-sink.json", + "list.json", + "bug427.json", + // The motivating repro for non-nullable union support: a + // heterogeneous array under a PHP-reserved-word property name. + "php-mixed-union.json", ], skipMiscJSON: true, - skipSchema: [], + skipSchema: [ + // PHP class names are case-insensitive, but the namer dedups + // case-sensitively, so this declares classes that collide (same + // reason Java and Python skip it). + "keyword-unions.schema", + // Unions are inlined as PHP union type declarations, so a + // top-level union produces no named TopLevel class for the driver. + "recursive-union-flattening.schema", + // The generated code for top-level enums is incompatible with the + // driver. + "top-level-enum.schema", + // The driver does not support top-level arrays. + "union.schema", + ], rendererOptions: {}, quickTestRendererOptions: [], sourceFiles: ["src/language/Php/index.ts"], From f64bef5e4a2b53bdbcc4acaaaf092076c99bf41b Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Tue, 14 Jul 2026 15:14:04 -0400 Subject: [PATCH 6/6] docs: state the fixture-first testing principle in CLAUDE.md Any change affecting generated output must be covered by the JSON / JSON Schema fixture tests; unit tests complement them but are never a substitute. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 3a1b7298d4..938400de68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,32 @@ # Repository conventions +## Testing + +quicktype's primary testing method is end-to-end fixture tests driven by JSON +and JSON Schema files. For each sample input, the fixture generates code for a +language, runs a driver program in `test/fixtures//` that +deserializes the sample and serializes it back, and compares the round-tripped +JSON to the input. + +- JSON inputs live in `test/inputs/json/`: `priority/` and `samples/` always + run, `misc/` is skipped under `QUICKTEST` (and for languages with + `skipMiscJSON`). +- JSON Schema inputs live in `test/inputs/schema/`: each `*.schema` comes with + `.N.json` samples and `.N.fail..json` expected-failure samples. A + fail sample must make the generated program exit nonzero; which fail + samples run is controlled by the language's `features` list. +- Per-language configuration — which inputs run (`skipJSON`, `includeJSON`, + `skipSchema`), renderer options, and `features` — lives in + `test/languages.ts`; fixtures are registered in `test/fixtures.ts`. +- Run one language's fixtures with `FIXTURE= script/test`, for example + `FIXTURE=php script/test` or `FIXTURE=schema-php script/test`. + +Any change that affects generated output MUST be covered by a JSON or JSON +Schema fixture test — by enabling existing inputs for the language or adding +new ones. Unit tests in `test/unit/` are a complement for what fixtures cannot +express (asserting that some code is *not* generated, API-level behavior, fast +local iteration) — never a substitute. + ## Releasing / version bumps Do not bump versions in any `package.json` before a release. Package manifest