From 926c52769f0c2c6d88c907092041f86e5b5e0e85 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 11:21:20 -0700 Subject: [PATCH 1/9] fix(effect-schema): preserve prototype-named map keys --- .../TypeScriptEffectSchemaRenderer.ts | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/quicktype-core/src/language/TypeScriptEffectSchema/TypeScriptEffectSchemaRenderer.ts b/packages/quicktype-core/src/language/TypeScriptEffectSchema/TypeScriptEffectSchemaRenderer.ts index b61a3764b2..4df2c713f5 100644 --- a/packages/quicktype-core/src/language/TypeScriptEffectSchema/TypeScriptEffectSchemaRenderer.ts +++ b/packages/quicktype-core/src/language/TypeScriptEffectSchema/TypeScriptEffectSchemaRenderer.ts @@ -155,9 +155,9 @@ export class TypeScriptEffectSchemaRenderer extends ConvenienceRenderer { }, (_classType) => panic("Should already be handled."), (_mapType) => [ - "S.Record({ key: S.String, value: ", + "mapSchema(", this.typeMapTypeFor(_mapType.values, false), - "})", + ")", ], (_enumType) => panic("Should already be handled."), (unionType) => { @@ -406,6 +406,26 @@ export class TypeScriptEffectSchemaRenderer extends ConvenienceRenderer { } this.emitImports(); + if (this.haveMaps) { + this.emitMultiline(` +const objectSchema = () => + S.declare( + (input): input is Record => + typeof input === "object" && input !== null && !Array.isArray(input) + ); +const mapSchema = (value: S.Schema) => { + const entries = S.transform(objectSchema(), S.Array(S.Tuple(S.String, value)), { + strict: false, + decode: Object.entries, + encode: Object.fromEntries + }); + return S.transform(entries, objectSchema(), { + strict: false, + decode: Object.fromEntries, + encode: Object.entries + }); +};`); + } this.emitSchemas(); } } From 1e4f074905b4854981c556990bdd3b2ee9c33d53 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 18:12:43 -0700 Subject: [PATCH 2/9] test(effect-schema): scope map helper emission --- .../effect-schema-map-helper-emission.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/unit/effect-schema-map-helper-emission.test.ts diff --git a/test/unit/effect-schema-map-helper-emission.test.ts b/test/unit/effect-schema-map-helper-emission.test.ts new file mode 100644 index 0000000000..67cd6c6ee7 --- /dev/null +++ b/test/unit/effect-schema-map-helper-emission.test.ts @@ -0,0 +1,38 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +async function renderEffectSchema(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ + inputData, + lang: "typescript-effect-schema", + }); + return result.lines.join("\n"); +} + +test("Effect Schema emits the map helper only for map types", async () => { + const objectOutput = await renderEffectSchema({ + type: "object", + properties: { value: { type: "boolean" } }, + }); + const mapOutput = await renderEffectSchema({ + type: "object", + additionalProperties: { type: "boolean" }, + }); + + expect(objectOutput).not.toContain("const mapSchema"); + expect(objectOutput).not.toContain("const objectSchema"); + expect(mapOutput).toContain("const mapSchema"); + expect(mapOutput).toContain("const objectSchema"); + expect(mapOutput).toContain("mapSchema(S.Boolean)"); +}); From e5892473444624959d9436c1f99f17991d6b3e0e Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 20:02:35 -0700 Subject: [PATCH 3/9] test(effect-schema): rely on fixture coverage --- .../effect-schema-map-helper-emission.test.ts | 38 ------------------- 1 file changed, 38 deletions(-) delete mode 100644 test/unit/effect-schema-map-helper-emission.test.ts diff --git a/test/unit/effect-schema-map-helper-emission.test.ts b/test/unit/effect-schema-map-helper-emission.test.ts deleted file mode 100644 index 67cd6c6ee7..0000000000 --- a/test/unit/effect-schema-map-helper-emission.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - InputData, - JSONSchemaInput, - quicktype, -} from "../../packages/quicktype-core/src/index.js"; -import { expect, test } from "vitest"; - -async function renderEffectSchema(schema: object): Promise { - const schemaInput = new JSONSchemaInput(undefined); - await schemaInput.addSource({ - name: "TopLevel", - schema: JSON.stringify(schema), - }); - const inputData = new InputData(); - inputData.addInput(schemaInput); - const result = await quicktype({ - inputData, - lang: "typescript-effect-schema", - }); - return result.lines.join("\n"); -} - -test("Effect Schema emits the map helper only for map types", async () => { - const objectOutput = await renderEffectSchema({ - type: "object", - properties: { value: { type: "boolean" } }, - }); - const mapOutput = await renderEffectSchema({ - type: "object", - additionalProperties: { type: "boolean" }, - }); - - expect(objectOutput).not.toContain("const mapSchema"); - expect(objectOutput).not.toContain("const objectSchema"); - expect(mapOutput).toContain("const mapSchema"); - expect(mapOutput).toContain("const objectSchema"); - expect(mapOutput).toContain("mapSchema(S.Boolean)"); -}); From 60c9220fefc4e56a730f2a991c48e4ca9d25ea76 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 08:06:51 -0700 Subject: [PATCH 4/9] fix(javascript): preserve prototype-named map keys --- .../language/JavaScript/JavaScriptRenderer.ts | 4 +-- test/inputs/schema/class-map-union.5.json | 5 ++++ test/unit/javascript-prototype-key.test.ts | 27 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 test/inputs/schema/class-map-union.5.json create mode 100644 test/unit/javascript-prototype-key.test.ts diff --git a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts index 63d5b5cd66..570d967941 100644 --- a/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts +++ b/packages/quicktype-core/src/language/JavaScript/JavaScriptRenderer.ts @@ -456,7 +456,7 @@ ${hasArrayConstraints ? ' if ((typ.min !== undefined && val.length < typ. if (val === null || typeof val !== "object" || Array.isArray(val)) { return invalidValue(l(ref || "object"), val, key, parent); } - const result${anyAnnotation} = {}; + const result${anyAnnotation} = Object.create(null); Object.getOwnPropertyNames(props).forEach(key => { const prop = props[key]; const v = Object.prototype.hasOwnProperty.call(val, key) ? val[key] : undefined; @@ -471,7 +471,7 @@ ${hasArrayConstraints ? ' if ((typ.min !== undefined && val.length < typ. }; } }); - return result; + return Object.setPrototypeOf(result, Object.prototype); } if (typ === "any") return val; diff --git a/test/inputs/schema/class-map-union.5.json b/test/inputs/schema/class-map-union.5.json new file mode 100644 index 0000000000..489945ea17 --- /dev/null +++ b/test/inputs/schema/class-map-union.5.json @@ -0,0 +1,5 @@ +{ + "union": { + "__proto__": true + } +} diff --git a/test/unit/javascript-prototype-key.test.ts b/test/unit/javascript-prototype-key.test.ts new file mode 100644 index 0000000000..34f43a6861 --- /dev/null +++ b/test/unit/javascript-prototype-key.test.ts @@ -0,0 +1,27 @@ +import vm from "node:vm"; + +import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; +import { expect, test } from "vitest"; + +test("JavaScript converters preserve prototype-named keys on plain objects", async () => { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify({ + type: "object", + additionalProperties: { type: "boolean" }, + }), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ inputData, lang: "javascript" }); + const module = { exports: {} as Record object> }; + + vm.runInNewContext(result.lines.join("\n"), { module, Object }); + const converted = module.exports.toTopLevel('{"__proto__":true}'); + + expect(Object.getPrototypeOf(converted)).toBe(Object.prototype); + expect(Object.getOwnPropertyDescriptor(converted, "__proto__")?.value).toBe( + true, + ); +}); From fa8a412fa7bb2f603aac15f6617966d58f1c310c Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 10:51:04 -0700 Subject: [PATCH 5/9] fix(elm): preserve prototype-named map keys --- .../quicktype-core/src/language/Elm/ElmRenderer.ts | 10 +++++++++- packages/quicktype-core/src/language/Elm/constants.ts | 1 + 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts index 22a0d92e83..d9c1171244 100644 --- a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts +++ b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts @@ -344,7 +344,7 @@ export class ElmRenderer extends ConvenienceRenderer { (mapType) => multiWord( " ", - "Jenc.dict", + "makeDictEncoder", "identity", parenIfNeeded(this.encoderNameForType(mapType.values)), ), @@ -801,6 +801,14 @@ import Dict exposing (Dict)`); this.emitLine("--- encoder helpers"); this.ensureBlankLine(); + this.emitMultiline(`makeDictEncoder : (String -> String) -> (a -> Jenc.Value) -> Dict String a -> Jenc.Value +makeDictEncoder f m r = + r + |> Dict.toList + |> List.map (\\( x, y ) -> Jenc.encode 0 (Jenc.string (f x)) ++ ":" ++ Jenc.encode 0 (m y)) + |> String.join "," + |> (\\str -> Jdec.decodeString Jdec.value ("{" ++ str ++ "}") |> Result.withDefault Jenc.null)`); + this.ensureBlankLine(); this.emitMultiline(`makeNullableEncoder : (a -> Jenc.Value) -> Maybe a -> Jenc.Value makeNullableEncoder f m = case m of diff --git a/packages/quicktype-core/src/language/Elm/constants.ts b/packages/quicktype-core/src/language/Elm/constants.ts index b4396fb684..07a5d50a1c 100644 --- a/packages/quicktype-core/src/language/Elm/constants.ts +++ b/packages/quicktype-core/src/language/Elm/constants.ts @@ -28,6 +28,7 @@ export const forbiddenNames = [ "Dict", "Maybe", "makeNullableEncoder", + "makeDictEncoder", "optionalField", // Parameter names used in generated functions. Elm 0.19 does not // allow a parameter to shadow a top-level definition. From 01209d3ecc57d324d755b30f3f2744066e8b6198 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 18:12:31 -0700 Subject: [PATCH 6/9] fix(elm): emit map encoder only when needed --- .../src/language/Elm/ElmRenderer.ts | 6 ++-- test/unit/elm-map-helper-emission.test.ts | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) create mode 100644 test/unit/elm-map-helper-emission.test.ts diff --git a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts index d9c1171244..3e5ac39f31 100644 --- a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts +++ b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts @@ -801,14 +801,16 @@ import Dict exposing (Dict)`); this.emitLine("--- encoder helpers"); this.ensureBlankLine(); - this.emitMultiline(`makeDictEncoder : (String -> String) -> (a -> Jenc.Value) -> Dict String a -> Jenc.Value + if (this.haveMaps) { + this.emitMultiline(`makeDictEncoder : (String -> String) -> (a -> Jenc.Value) -> Dict String a -> Jenc.Value makeDictEncoder f m r = r |> Dict.toList |> List.map (\\( x, y ) -> Jenc.encode 0 (Jenc.string (f x)) ++ ":" ++ Jenc.encode 0 (m y)) |> String.join "," |> (\\str -> Jdec.decodeString Jdec.value ("{" ++ str ++ "}") |> Result.withDefault Jenc.null)`); - this.ensureBlankLine(); + this.ensureBlankLine(); + } this.emitMultiline(`makeNullableEncoder : (a -> Jenc.Value) -> Maybe a -> Jenc.Value makeNullableEncoder f m = case m of diff --git a/test/unit/elm-map-helper-emission.test.ts b/test/unit/elm-map-helper-emission.test.ts new file mode 100644 index 0000000000..601b60a7de --- /dev/null +++ b/test/unit/elm-map-helper-emission.test.ts @@ -0,0 +1,33 @@ +import { + InputData, + JSONSchemaInput, + quicktype, +} from "../../packages/quicktype-core/src/index.js"; +import { expect, test } from "vitest"; + +async function renderElm(schema: object): Promise { + const schemaInput = new JSONSchemaInput(undefined); + await schemaInput.addSource({ + name: "TopLevel", + schema: JSON.stringify(schema), + }); + const inputData = new InputData(); + inputData.addInput(schemaInput); + const result = await quicktype({ inputData, lang: "elm" }); + return result.lines.join("\n"); +} + +test("Elm emits the dictionary encoder only for map types", async () => { + const objectOutput = await renderElm({ + type: "object", + properties: { value: { type: "boolean" } }, + }); + const mapOutput = await renderElm({ + type: "object", + additionalProperties: { type: "boolean" }, + }); + + expect(objectOutput).not.toContain("makeDictEncoder :"); + expect(mapOutput).toContain("makeDictEncoder :"); + expect(mapOutput).toContain("makeDictEncoder identity Jenc.bool"); +}); From fc7e58c31a0cf1f2b38ff1ec2101750d6e8b1e0d Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 19:56:32 -0700 Subject: [PATCH 7/9] test(javascript): avoid duplicate prototype assertion --- test/unit/javascript-prototype-key.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/test/unit/javascript-prototype-key.test.ts b/test/unit/javascript-prototype-key.test.ts index 34f43a6861..41e93de7eb 100644 --- a/test/unit/javascript-prototype-key.test.ts +++ b/test/unit/javascript-prototype-key.test.ts @@ -3,7 +3,7 @@ import vm from "node:vm"; import { InputData, JSONSchemaInput, quicktype } from "quicktype-core"; import { expect, test } from "vitest"; -test("JavaScript converters preserve prototype-named keys on plain objects", async () => { +test("JavaScript converters return plain objects for prototype-named keys", async () => { const schemaInput = new JSONSchemaInput(undefined); await schemaInput.addSource({ name: "TopLevel", @@ -21,7 +21,4 @@ test("JavaScript converters preserve prototype-named keys on plain objects", asy const converted = module.exports.toTopLevel('{"__proto__":true}'); expect(Object.getPrototypeOf(converted)).toBe(Object.prototype); - expect(Object.getOwnPropertyDescriptor(converted, "__proto__")?.value).toBe( - true, - ); }); From 4d7f7db0019f06d7646a77cdd3819c0fcf1d2e50 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 20:02:43 -0700 Subject: [PATCH 8/9] test(elm): rely on map fixture coverage --- test/unit/elm-map-helper-emission.test.ts | 33 ----------------------- 1 file changed, 33 deletions(-) delete mode 100644 test/unit/elm-map-helper-emission.test.ts diff --git a/test/unit/elm-map-helper-emission.test.ts b/test/unit/elm-map-helper-emission.test.ts deleted file mode 100644 index 601b60a7de..0000000000 --- a/test/unit/elm-map-helper-emission.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - InputData, - JSONSchemaInput, - quicktype, -} from "../../packages/quicktype-core/src/index.js"; -import { expect, test } from "vitest"; - -async function renderElm(schema: object): Promise { - const schemaInput = new JSONSchemaInput(undefined); - await schemaInput.addSource({ - name: "TopLevel", - schema: JSON.stringify(schema), - }); - const inputData = new InputData(); - inputData.addInput(schemaInput); - const result = await quicktype({ inputData, lang: "elm" }); - return result.lines.join("\n"); -} - -test("Elm emits the dictionary encoder only for map types", async () => { - const objectOutput = await renderElm({ - type: "object", - properties: { value: { type: "boolean" } }, - }); - const mapOutput = await renderElm({ - type: "object", - additionalProperties: { type: "boolean" }, - }); - - expect(objectOutput).not.toContain("makeDictEncoder :"); - expect(mapOutput).toContain("makeDictEncoder :"); - expect(mapOutput).toContain("makeDictEncoder identity Jenc.bool"); -}); From d42d8eb782b4ee33a823f36eaaefff7e7330a9c7 Mon Sep 17 00:00:00 2001 From: Mark Probst Date: Mon, 7 Sep 2026 20:13:37 -0700 Subject: [PATCH 9/9] fix(elm): scope dictionary encoder name --- .../src/language/Elm/ElmRenderer.ts | 2 ++ .../quicktype-core/src/language/Elm/constants.ts | 1 - test/inputs/json/priority/keywords.json | 15 ++++++++------- test/inputs/schema/keyword-enum.schema | 1 + test/inputs/schema/keyword-unions.schema | 7 +++++++ test/keywords.txt | 1 + 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts index 3e5ac39f31..240869dd07 100644 --- a/packages/quicktype-core/src/language/Elm/ElmRenderer.ts +++ b/packages/quicktype-core/src/language/Elm/ElmRenderer.ts @@ -67,6 +67,8 @@ export class ElmRenderer extends ConvenienceRenderer { } protected forbiddenNamesForGlobalNamespace(): readonly string[] { + if (this.forEachType((type) => type.kind).has("map")) + return [...forbiddenNames, "makeDictEncoder"]; return forbiddenNames; } diff --git a/packages/quicktype-core/src/language/Elm/constants.ts b/packages/quicktype-core/src/language/Elm/constants.ts index 07a5d50a1c..b4396fb684 100644 --- a/packages/quicktype-core/src/language/Elm/constants.ts +++ b/packages/quicktype-core/src/language/Elm/constants.ts @@ -28,7 +28,6 @@ export const forbiddenNames = [ "Dict", "Maybe", "makeNullableEncoder", - "makeDictEncoder", "optionalField", // Parameter names used in generated functions. Elm 0.19 does not // allow a parameter to shadow a top-level definition. diff --git a/test/inputs/json/priority/keywords.json b/test/inputs/json/priority/keywords.json index bd1573fac7..672625c26b 100644 --- a/test/inputs/json/priority/keywords.json +++ b/test/inputs/json/priority/keywords.json @@ -186,6 +186,7 @@ "Locale": { "Locale": 123 }, "lock": { "lock": 123 }, "long": { "long": 123 }, + "makeDictEncoder": { "makeDictEncoder": 123 }, "map": { "map": 123 }, "MarshalJSON": { "MarshalJSON": 123 }, "MapEntry": { "MapEntry": 123 }, @@ -197,11 +198,11 @@ "native": { "native": 123 }, "new": { "new": 123 }, "newtonsoft": { "newtonsoft": 123 }, - "nil": { "nil": 123 }, - "NO": { "NO": 123 }, "dummy": 123 }, "obj4": { + "nil": { "nil": 123 }, + "NO": { "NO": 123 }, "noexcept": { "noexcept": 123 }, "nonatomic": { "nonatomic": 123 }, "none": { "none": 123 }, @@ -264,11 +265,11 @@ "runtimeType": { "runtimeType": 123 }, "s": { "s": 123 }, "sbyte": { "sbyte": 123 }, - "sealed": { "sealed": 123 }, - "SEL": { "SEL": 123 }, "dummy": 123 }, "obj5": { + "sealed": { "sealed": 123 }, + "SEL": { "SEL": 123 }, "select": { "select": 123 }, "Self": { "Self": 123 }, "Serializable": { "Serializable": 123 }, @@ -331,12 +332,12 @@ "undefined": { "undefined": 123 }, "union": { "union": 123 }, "UnmarshalJSON": { "UnmarshalJSON": 123 }, - "UseSerializers": { "UseSerializers": 123 }, - "unowned": { "unowned": 123 }, - "unsafe": { "unsafe": 123 }, "dummy": 123 }, "obj6": { + "UseSerializers": { "UseSerializers": 123 }, + "unowned": { "unowned": 123 }, + "unsafe": { "unsafe": 123 }, "unsigned": { "unsigned": 123 }, "ushort": { "ushort": 123 }, "using": { "using": 123 }, diff --git a/test/inputs/schema/keyword-enum.schema b/test/inputs/schema/keyword-enum.schema index 01b8bc3559..127453aaaf 100644 --- a/test/inputs/schema/keyword-enum.schema +++ b/test/inputs/schema/keyword-enum.schema @@ -184,6 +184,7 @@ "Locale", "lock", "long", + "makeDictEncoder", "map", "MarshalJSON", "MapEntry", diff --git a/test/inputs/schema/keyword-unions.schema b/test/inputs/schema/keyword-unions.schema index 034a0eedfc..de53dccd37 100644 --- a/test/inputs/schema/keyword-unions.schema +++ b/test/inputs/schema/keyword-unions.schema @@ -1261,6 +1261,13 @@ ], "title": "union_long" }, + "makeDictEncoder": { + "oneOf": [ + { "type": "number" }, + { "type": "object", "additionalProperties": false, "title": "makeDictEncoder" } + ], + "title": "union_makeDictEncoder" + }, "map": { "oneOf": [ { "type": "number" }, diff --git a/test/keywords.txt b/test/keywords.txt index ff7c572a7c..f1b59336e9 100644 --- a/test/keywords.txt +++ b/test/keywords.txt @@ -178,6 +178,7 @@ list Locale lock long +makeDictEncoder map MarshalJSON MapEntry