From 33c31465a99705a1ca7b1e5410ac9dd4b9acbd35 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 07:01:45 +0200 Subject: [PATCH 1/6] fix(encoding/json): type-drive Unmarshal into Map/Slice/Pointer containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unmarshal only special-cased struct fields/targets whose runtime value was already an instance of the right shape; anything else fell through to a generic "objects become Maps" decode with no awareness of Go element types. Three related gaps, all traced to the same missing piece — the decoder needs to walk a field/element's declared TypeInfo, not just the JSON shape it sees: 1. map[string]Struct / map[string][]Struct fields (and further nesting, e.g. map[string][]Struct) left plain JS objects/arrays in place instead of real struct instances, so generated struct-field accessors read back undefined: type Ref struct{ Name string `json:"name"` } type Container struct { Contexts map[string][]Ref `json:"contexts"` } json.Unmarshal([]byte(`{"contexts":{"a":[{"name":"x"}]}}`), &c) // c.Contexts["a"][0].Name read back "" (plain object, not a Ref) 2. An interface{}-typed array of objects left its element objects as raw plain JS objects instead of this override's Map representation, so a `case map[string]any:` type switch on an element panicked calling Map-only methods like .entries(): var v any json.Unmarshal([]byte(`[{"x":1},{"y":2}]`), &v) // v.([]any)[0] was a plain object, not the Map every other // interface{}-typed object value decodes to 3. json.RawMessage wasn't recognized as a map/slice ELEMENT type (only as a direct field type), and there was no Pointer branch in the type-driven path at all, so pointer elements/fields (map[string]*Property, Items *ItemsSpec) leaked plain objects with undefined Go field reads. Fix: a recursive decodeValueForType(decoded, typeInfo, opts) that decodes a raw JSON value per its Go TypeInfo — Struct constructs a real instance via the registered ctor, Slice/Map recurse per elemType, Pointer decodes as its pointee, RawMessage captures the raw fragment as bytes, and anything else (including interface{}) falls back to a new decodeInterfaceValue that replaces objectToMap and additionally recurses into array elements (fixing case 2 for nested and top-level arrays alike). Wired in at both call sites: assignDecodedFieldValue (struct fields) and assignDecodedValue's new top-level branch, which resolves a var's own type via the __goType spelling the runtime boxes onto Unmarshal's `any` target — needed for e.g. `var m map[string]json.RawMessage` where there is no struct field to hang type info off of. Extends gs/encoding/json/index.test.ts with one case per bug (map[string] []Struct field, interface{} array-of-objects, pointer-to-struct map value + field, and RawMessage as both a map and slice element). All four fail on master and pass with this fix; verified by temporarily reverting just the source change and confirming the new tests fail with the same errors described above. Co-Authored-By: Claude Fable 5 Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 187 ++++++++++++++++++++++++++ gs/encoding/json/index.ts | 233 ++++++++++++++++++++++++++++++++- 2 files changed, 413 insertions(+), 7 deletions(-) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 17563b26..598d4322 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -85,6 +85,124 @@ class FieldAlias { ) } +class Ref { + public _fields = { + Name: $.varRef(''), + } + + static __typeInfo = $.registerStructType( + 'test.Ref', + new Ref(), + [], + Ref, + [ + { + name: 'Name', + key: 'Name', + type: { kind: $.TypeKind.Basic, name: 'string' }, + tag: 'json:"name"', + }, + ], + ) +} + +class Container { + public _fields = { + Contexts: $.varRef | null>(null), + } + + static __typeInfo = $.registerStructType( + 'test.Container', + new Container(), + [], + Container, + [ + { + name: 'Contexts', + key: 'Contexts', + type: { + kind: $.TypeKind.Map, + elemType: { kind: $.TypeKind.Slice, elemType: 'test.Ref' }, + }, + tag: 'json:"contexts"', + }, + ], + ) +} + +class Property { + public _fields = { + Type: $.varRef(''), + } + + static __typeInfo = $.registerStructType( + 'test.Property', + new Property(), + [], + Property, + [ + { + name: 'Type', + key: 'Type', + type: { kind: $.TypeKind.Basic, name: 'string' }, + tag: 'json:"type"', + }, + ], + ) +} + +class Schema { + public _fields = { + Properties: $.varRef | null>(null), + Items: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.Schema', + new Schema(), + [], + Schema, + [ + { + name: 'Properties', + key: 'Properties', + type: { + kind: $.TypeKind.Map, + elemType: { kind: $.TypeKind.Pointer, elemType: 'test.Property' }, + }, + tag: 'json:"properties"', + }, + { + name: 'Items', + key: 'Items', + type: { kind: $.TypeKind.Pointer, elemType: 'test.Property' }, + tag: 'json:"items"', + }, + ], + ) +} + +class Holder { + public _fields = { + Value: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.Holder', + new Holder(), + [], + Holder, + [ + { + name: 'Value', + key: 'Value', + type: { kind: $.TypeKind.Interface, methods: [] }, + tag: 'json:"value"', + }, + ], + ) +} + describe('encoding/json override', () => { it('registers the Unmarshaler interface shape', () => { class CustomMarshaler implements Marshaler { @@ -361,6 +479,75 @@ describe('encoding/json override', () => { expect(mapRef.value?.get('active')).toBe(true) }) + it('decodes a map[string][]Struct field into real struct instances, not plain objects/arrays', () => { + const target = $.varRef(new Container()) + const err = Unmarshal( + $.stringToBytes('{"contexts":{"a":[{"name":"x"},{"name":"y"}]}}'), + target, + ) + + expect(err).toBeNull() + const contexts = target.value._fields.Contexts.value + expect(contexts).toBeInstanceOf(Map) + const refs = contexts?.get('a') + expect(refs).toHaveLength(2) + expect(refs?.[0]._fields.Name.value).toBe('x') + expect(refs?.[1]._fields.Name.value).toBe('y') + }) + + it('decodes array elements inside an interface{}-typed field into Maps, not plain objects', () => { + const target = $.varRef(new Holder()) + const err = Unmarshal( + $.stringToBytes('{"value":[{"x":1},{"y":2}]}'), + target, + ) + + expect(err).toBeNull() + const value = target.value._fields.Value.value as unknown[] + expect(Array.isArray(value)).toBe(true) + expect(value[0]).toBeInstanceOf(Map) + expect((value[0] as Map).get('x')).toBe(1) + expect((value[1] as Map).get('y')).toBe(2) + }) + + it('decodes pointer-to-struct map values and fields into real struct instances', () => { + const target = $.varRef(new Schema()) + const err = Unmarshal( + $.stringToBytes( + '{"properties":{"width":{"type":"number"}},"items":{"type":"string"}}', + ), + target, + ) + + expect(err).toBeNull() + const properties = target.value._fields.Properties.value + expect(properties?.get('width')?._fields.Type.value).toBe('number') + expect(target.value._fields.Items.value?._fields.Type.value).toBe( + 'string', + ) + }) + + it('decodes json.RawMessage as a map and slice element type', () => { + // The runtime boxes Unmarshal's `any` target with its Go type spelling + // (__goType) at the call site; simulate that here the way $.interfaceValue + // would for `var m map[string]json.RawMessage; json.Unmarshal(data, &m)`. + const mapTarget = $.varRef | null>(new Map()) + mapTarget.__goType = '*map[string]json.RawMessage' + expect( + Unmarshal($.stringToBytes('{"a":{"x":1},"b":[1,2,3]}'), mapTarget), + ).toBeNull() + expect($.bytesToString(mapTarget.value!.get('a')!)).toBe('{"x":1}') + expect($.bytesToString(mapTarget.value!.get('b')!)).toBe('[1,2,3]') + + const sliceTarget = $.varRef([]) + sliceTarget.__goType = '*[]json.RawMessage' + expect( + Unmarshal($.stringToBytes('[{"x":1},"raw"]'), sliceTarget), + ).toBeNull() + expect($.bytesToString(sliceTarget.value[0])).toBe('{"x":1}') + expect($.bytesToString(sliceTarget.value[1])).toBe('"raw"') + }) + it('encodes JSON to an io.Writer with newline, indentation, and HTML escaping', () => { const chunks: string[] = [] const writer = { diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index 65633694..4020f11e 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1013,8 +1013,29 @@ function assignDecodedValue( target.value = base64Decode(decoded) return } - if (isPlainObject(decoded)) { - target.value = objectToMap(decoded) + // A top-level map/slice var carries its Go type spelling (including its + // element type) via __goType, which the runtime boxes onto Unmarshal's + // `any` target (see unmarshalTargetGoType/goTypeDescriptor below). + // Decoding per that spelling, instead of the shape-driven decode below, + // is what makes e.g. `var m map[string]json.RawMessage` or + // `var m map[string]*Property` populate real typed elements instead of + // plain JS objects/strings. + const spelling = unmarshalTargetGoType(target) + if (spelling !== null) { + const desc = goTypeDescriptor(spelling) + const info = resolveTypeInfo(desc) + if (info !== null) { + if ( + ($.isSliceTypeInfo(info) && Array.isArray(decoded)) || + ($.isMapTypeInfo(info) && isPlainObject(decoded)) + ) { + target.value = decodeValueForType(decoded, desc, opts) + return + } + } + } + if (isPlainObject(decoded) || Array.isArray(decoded)) { + target.value = decodeInterfaceValue(decoded) return } target.value = decoded @@ -1070,15 +1091,213 @@ function assignDecodedFieldValue( target.value = $.stringToBytes(JSON.stringify(decoded)) return } + // Decode Map/Slice-typed fields (including nested combinations, e.g. + // map[string][]Struct) through the type-driven decoder below. Gated on the + // decoded JSON shape actually matching (array for Slice, object for Map) so + // every other field shape (basic types, []byte-as-base64-string, etc.) + // falls through to the untouched generic path exactly as before. + const info = resolveTypeInfo(fieldType) + if (info !== null) { + if ($.isSliceTypeInfo(info) && Array.isArray(decoded)) { + target.value = decodeValueForType(decoded, info, opts) + return + } + if ($.isMapTypeInfo(info) && isPlainObject(decoded)) { + target.value = decodeValueForType(decoded, info, opts) + return + } + // Pointer-to-struct fields (e.g. Items *ItemsSpec) start as nil, so the + // generic path below has no zero-value instance to populate — construct + // the pointee via the type-driven decoder. Pointer-to-basic fields keep + // the generic path (their representation is not a struct instance). + if ($.isPointerTypeInfo(info) && isPlainObject(decoded)) { + const pointee = resolveTypeInfo(info.elemType) + if (pointee !== null && $.isStructTypeInfo(pointee)) { + target.value = decodeValueForType(decoded, info.elemType, opts) + return + } + } + } assignDecodedValue(target, decoded, opts) } -function objectToMap(decoded: Record): Map { - const out = new Map() - for (const [key, value] of Object.entries(decoded)) { - out.set(key, isPlainObject(value) ? objectToMap(value) : value) +// resolveTypeInfo normalizes a type descriptor (an inline TypeInfo object, or +// a qualified type-name string like "pkg.MyStruct") into a TypeInfo, looking +// up named types via $.getTypeByName. Returns null when the descriptor is +// missing or names a type the runtime has no registration for (e.g. a basic +// type name like "uint8" — those are handled as plain values). +function resolveTypeInfo(t: unknown): $.TypeInfo | null { + if (typeof t === 'string') { + return ($.getTypeByName(t) as $.TypeInfo | undefined) ?? null } - return out + if (typeof t === 'object' && t !== null && 'kind' in t) { + return t as $.TypeInfo + } + return null +} + +// decodeValueForType recursively decodes a raw JSON-parsed JS value into a +// properly-typed Go value per `typeInfo`: +// - Struct: construct the registered ctor and populate its fields. +// - Slice: decode each element per the slice's elemType (struct elements +// become real struct instances instead of leaking plain objects). +// - Map: build a real Map (this override's Go-map representation) whose +// values are decoded per the map's elemType — this is what fixes +// map[string]T struct/slice-of-struct fields (e.g. a +// `Contexts map[string][]Ref` field): a struct- or slice-valued MAP +// field previously fell through to the shape-driven interface{} decode +// below and silently produced a Map of plain JS objects/arrays instead +// of real struct instances, so Go field access on the decoded values +// (e.g. `contexts.Foo`) read back empty/undefined. +// - json.RawMessage element: captures the raw JSON fragment as bytes, +// mirroring the direct-struct-field RawMessage path in +// assignDecodedFieldValue above. +// - Pointer element: decodes as its pointee (the runtime represents a +// *Struct value as the struct instance itself for field access) — +// without this, map/slice elements typed e.g. map[string]*Property +// leaked plain objects whose Go field reads came back undefined. +// - Missing type info / interface{} elements: shape-driven decode via +// decodeInterfaceValue, mirroring Go's json.Unmarshal-into-any. +function decodeValueForType( + decoded: unknown, + typeInfo: unknown, + opts: decodeOptions, +): unknown { + if (isRawMessageType(typeInfo) && decoded !== undefined) { + return $.stringToBytes(JSON.stringify(decoded)) + } + if (decoded === null || decoded === undefined) { + return decoded + } + const info = resolveTypeInfo(typeInfo) + if (info === null || $.isInterfaceTypeInfo(info)) { + return decodeInterfaceValue(decoded) + } + if ($.isPointerTypeInfo(info)) { + return decodeValueForType(decoded, info.elemType, opts) + } + if ($.isStructTypeInfo(info)) { + if (!isPlainObject(decoded) || typeof info.ctor !== 'function') { + return decoded + } + const inst = new info.ctor() + if (isStructValue(inst)) { + assignStructFields(inst, decoded, opts) + } + return $.markAsStructValue(inst) + } + if ($.isSliceTypeInfo(info)) { + if (!Array.isArray(decoded)) { + return decoded + } + return decoded.map((el) => decodeValueForType(el, info.elemType, opts)) + } + if ($.isMapTypeInfo(info)) { + if (!isPlainObject(decoded)) { + return decoded + } + const out = new Map() + for (const [key, value] of Object.entries(decoded)) { + out.set(key, decodeValueForType(value, info.elemType, opts)) + } + return out + } + // Basic / other kinds: passthrough (RawMessage and []byte-as-base64 are + // handled by the caller before reaching the generic decoder). + return decoded +} + +// decodeInterfaceValue recursively decodes a raw JSON-parsed JS value the way +// Go's json.Unmarshal decodes into interface{}: objects become Maps (this +// override's Go-map representation) and arrays are decoded element-wise, so +// nested objects INSIDE arrays also become Maps, not just direct object +// values — both recursively, all the way down. Primitives pass through +// unchanged. +// +// Supersedes objectToMap, which only recursed into direct object VALUES, not +// array elements: an interface{}-typed array of objects (e.g. a Go []any +// holding map[string]any elements) left its element objects as raw plain JS +// objects instead of Maps. Go-side code that then type-switches on +// `case map[string]any:` and calls Map-only methods like .entries() on those +// elements panics with "entries is not a function". +function decodeInterfaceValue(decoded: unknown): unknown { + if (Array.isArray(decoded)) { + return decoded.map((el) => decodeInterfaceValue(el)) + } + if (isPlainObject(decoded)) { + const out = new Map() + for (const [key, value] of Object.entries(decoded)) { + out.set(key, decodeInterfaceValue(value)) + } + return out + } + return decoded +} + +// unmarshalTargetGoType returns the Go type spelling the runtime's +// $.interfaceValue boxes onto an Unmarshal target as __goType (e.g. +// `*map[string]json.RawMessage`), without the leading pointer star. Null +// when the target carries none (e.g. it was never passed through an `any` +// parameter, so there is no spelling to decode against). +function unmarshalTargetGoType(target: unknown): string | null { + if (target === null || typeof target !== 'object') { + return null + } + const t = Reflect.get(target, '__goType') + if (typeof t !== 'string' || t === '') { + return null + } + return t.startsWith('*') ? t.slice(1) : t +} + +// goTypeDescriptor parses a Go type spelling (from __goType, see above) into +// a decode descriptor consumable by decodeValueForType / resolveTypeInfo: +// - 'json.RawMessage' RawMessage marker (isRawMessageType) +// - {kind: Slice|Map, elemType} for []T / map[K]V +// - the spelling itself for named types ($.getTypeByName resolves) +// - null for interface{} and unparseable spellings +// (shape-driven decode, same as before) +function goTypeDescriptor(spelling: string): unknown { + let s = spelling.trim() + while (s.startsWith('*')) { + s = s.slice(1).trim() + } + if (s === 'json.RawMessage' || s === 'encoding/json.RawMessage') { + return 'json.RawMessage' + } + if (s === 'any' || s === 'interface{}' || s === 'interface {}') { + return null + } + if (s.startsWith('[]')) { + return { kind: $.TypeKind.Slice, elemType: goTypeDescriptor(s.slice(2)) } + } + if (s.startsWith('map[')) { + const keyEnd = matchingBracketIndex(s, 'map['.length - 1) + if (keyEnd < 0) { + return null + } + return { + kind: $.TypeKind.Map, + elemType: goTypeDescriptor(s.slice(keyEnd + 1)), + } + } + return s +} + +// matchingBracketIndex returns the index of the ']' matching the '[' at +// `open`, or -1. Tracks nesting only — Go map key spellings carry no strings +// or further brackets in the shapes goTypeDescriptor parses (map keys are +// always basic types). +function matchingBracketIndex(s: string, open: number): number { + let depth = 0 + for (let i = open; i < s.length; i++) { + if (s[i] === '[') depth++ + else if (s[i] === ']') { + depth-- + if (depth === 0) return i + } + } + return -1 } function isStructValue( From 1cc7a96065b76a08b5bdd1b87761d8f1832bafd5 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 07:21:16 +0200 Subject: [PATCH 2/6] fix(encoding/json): keep pointer-to-struct decode unmarked Addresses review feedback on #145: pointer-to-struct fields/elements decoded to instances marked via markAsStructValue, but the runtime's pointer type matching (matchesPointerType in gs/builtin/type.ts) requires a struct pointee to stay UNMARKED, since marking is reserved for genuine non-pointer struct values. A decoded *Struct value therefore matched a Struct-by-value type assertion and failed a *Struct assertion, the inverse of correct Go semantics, which would break type switches and pointer-receiver method-set checks on JSON-decoded values. Three related spots, all part of the same gap: - decodeValueForType's Pointer branch recursed into its elemType and let the generic Struct branch mark the result. Now it constructs the pointee directly (mirroring the Struct branch's construction, minus the mark) for struct pointees, and only recurses for non-struct pointees (which carry no marking). - assignDecodedFieldValue's pointer-to-struct field routing called decodeValueForType with the field's elemType, bypassing the Pointer branch (and its now-correct handling) entirely. Now it passes the Pointer TypeInfo itself, so both call sites share one code path. - goTypeDescriptor unconditionally stripped every leading '*' while recursively parsing a __goType spelling, so a nested pointer element type (e.g. the *test.Property in map[string]*test.Property) lost its pointer-ness before a TypeInfo was ever built for it. Now a leading '*' produces a Pointer descriptor instead of being discarded; only the outermost address-of star (the Unmarshal target's own &var) is stripped, by unmarshalTargetGoType, before this function ever runs. Extends gs/encoding/json/index.test.ts with two cases verifying the decoded value matches *Struct and not Struct via the public $.is() API (one for inline struct-field TypeInfo, one for a __goType spelling with a nested pointer element). Both fail on the prior commit and pass with this fix, verified by temporarily reverting just the source change. Co-Authored-By: Claude Fable 5 Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 52 ++++++++++++++++++++++++++++++++++ gs/encoding/json/index.ts | 47 ++++++++++++++++++++++++++---- 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 598d4322..8029a696 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -527,6 +527,58 @@ describe('encoding/json override', () => { ) }) + it('decodes pointer-to-struct values as unmarked pointees, matching *Struct not Struct by value', () => { + const target = $.varRef(new Schema()) + const err = Unmarshal( + $.stringToBytes( + '{"properties":{"width":{"type":"number"}},"items":{"type":"string"}}', + ), + target, + ) + expect(err).toBeNull() + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + + // A *Property field/map-value must match *Property (pointer), not + // Property (value): the runtime distinguishes pointer vs. value structs + // with a marker, and getting this wrong breaks Go type + // assertions/switches and pointer-receiver method-set checks on + // JSON-decoded values. + const items = target.value._fields.Items.value + expect($.is(items, propertyPointerType)).toBe(true) + expect($.is(items, propertyType)).toBe(false) + + const width = target.value._fields.Properties.value?.get('width') + expect($.is(width, propertyPointerType)).toBe(true) + expect($.is(width, propertyType)).toBe(false) + }) + + it('preserves pointer element types when parsing a top-level __goType spelling', () => { + // Regression: parsing a __goType spelling used to strip every leading + // '*' while recursing into nested element types, collapsing + // map[string]*test.Property's element type down to test.Property and + // losing pointer-vs-value semantics for the decoded elements. + const target = $.varRef | null>(new Map()) + target.__goType = '*map[string]*test.Property' + expect( + Unmarshal($.stringToBytes('{"width":{"type":"number"}}'), target), + ).toBeNull() + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + const width = target.value?.get('width') + expect(width?._fields.Type.value).toBe('number') + expect($.is(width, propertyPointerType)).toBe(true) + expect($.is(width, propertyType)).toBe(false) + }) + it('decodes json.RawMessage as a map and slice element type', () => { // The runtime boxes Unmarshal's `any` target with its Go type spelling // (__goType) at the call site; simulate that here the way $.interfaceValue diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index 4020f11e..4cc9a6dd 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1108,12 +1108,15 @@ function assignDecodedFieldValue( } // Pointer-to-struct fields (e.g. Items *ItemsSpec) start as nil, so the // generic path below has no zero-value instance to populate — construct - // the pointee via the type-driven decoder. Pointer-to-basic fields keep - // the generic path (their representation is not a struct instance). + // the pointee via the type-driven decoder. Decode with the Pointer + // TypeInfo itself (not its elemType) so decodeValueForType's Pointer + // branch handles the pointer-vs-value marking correctly; see its + // comment. Pointer-to-basic fields keep the generic path (their + // representation is not a struct instance). if ($.isPointerTypeInfo(info) && isPlainObject(decoded)) { const pointee = resolveTypeInfo(info.elemType) if (pointee !== null && $.isStructTypeInfo(pointee)) { - target.value = decodeValueForType(decoded, info.elemType, opts) + target.value = decodeValueForType(decoded, info, opts) return } } @@ -1174,6 +1177,26 @@ function decodeValueForType( return decodeInterfaceValue(decoded) } if ($.isPointerTypeInfo(info)) { + // A *Struct pointee must be constructed directly here, NOT by recursing + // into the Struct branch below: that branch always marks its result via + // $.markAsStructValue, but the runtime's pointer matching + // (matchesPointerType in gs/builtin/type.ts) requires struct pointees to + // stay UNMARKED — only a genuine non-pointer struct value is marked. + // Getting this wrong breaks *Struct type assertions/switches and + // pointer-receiver method-set checks on decoded values. + const pointee = resolveTypeInfo(info.elemType) + if (pointee !== null && $.isStructTypeInfo(pointee)) { + if (!isPlainObject(decoded) || typeof pointee.ctor !== 'function') { + return decoded + } + const inst = new pointee.ctor() + if (isStructValue(inst)) { + assignStructFields(inst, decoded, opts) + } + return inst + } + // Non-struct pointee (e.g. *string): decode as the pointee's own + // representation, which carries no pointer-vs-value marking. return decodeValueForType(decoded, info.elemType, opts) } if ($.isStructTypeInfo(info)) { @@ -1252,15 +1275,27 @@ function unmarshalTargetGoType(target: unknown): string | null { // goTypeDescriptor parses a Go type spelling (from __goType, see above) into // a decode descriptor consumable by decodeValueForType / resolveTypeInfo: +// - {kind: Pointer, elemType} for *T (e.g. a map[string]*T element +// spelling) — NOT stripped, so +// decodeValueForType's Pointer branch can +// apply the correct pointer-vs-value marking +// (see its comment). Only the single +// outermost star representing the Unmarshal +// target's own address-of is stripped, by +// unmarshalTargetGoType above, before this +// function ever runs. // - 'json.RawMessage' RawMessage marker (isRawMessageType) // - {kind: Slice|Map, elemType} for []T / map[K]V // - the spelling itself for named types ($.getTypeByName resolves) // - null for interface{} and unparseable spellings // (shape-driven decode, same as before) function goTypeDescriptor(spelling: string): unknown { - let s = spelling.trim() - while (s.startsWith('*')) { - s = s.slice(1).trim() + const s = spelling.trim() + if (s.startsWith('*')) { + return { + kind: $.TypeKind.Pointer, + elemType: goTypeDescriptor(s.slice(1).trim()), + } } if (s === 'json.RawMessage' || s === 'encoding/json.RawMessage') { return 'json.RawMessage' From c18d36ad3d564c7234d247cb6a231eb782a98af7 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 17:00:32 +0200 Subject: [PATCH 3/6] fix(encoding/json): honor UnmarshalJSON on pointer-to-struct fields paralin's review on #145 flagged that the pointer-to-struct decode path added in this branch bypasses UnmarshalJSON: assignDecodedFieldValue routed straight into decodeValueForType and returned, so a *T field whose T implements UnmarshalJSON got field-by-field populated into a fresh instance instead of decoded through the custom hook Go's encoding/json would always prefer. Two spots needed the hook check, matching the fix direction from the review: - assignDecodedFieldValue now checks unmarshalJSONTarget(target.value) before routing into the type-driven constructor. A field that already holds a non-nil pointer whose pointee implements UnmarshalJSON falls through to assignDecodedValue, which invokes the hook on that existing instance in place (matching Go, which decodes into the pointee rather than allocating a replacement). - decodeValueForType's Pointer branch now checks the freshly allocated pointee for UnmarshalJSON before falling back to assignStructFields. This covers the nil-field case (nothing exists yet to check the hook against until after allocation) and also map/slice pointer-to-struct elements, which go through this same branch. Adds two regression tests to gs/encoding/json/index.test.ts: a nil pointer field decoding through the hook, and a non-nil pointer field decoding into the existing pointee in place (checked via object identity, not just field content). Both fail on the prior commit and pass with this fix, verified by temporarily reverting just the source change. Verify: bunx vitest run gs/encoding/json/index.test.ts Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 66 ++++++++++++++++++++++++++++++++++ gs/encoding/json/index.ts | 25 +++++++++++++ 2 files changed, 91 insertions(+) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 8029a696..88df9a78 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -182,6 +182,46 @@ class Schema { ) } +class HookPointee { + public _fields = {} + public Text = '' + public Marker = '' + + UnmarshalJSON(data: $.Slice): $.GoError { + this.Text = $.bytesToString(data) + return null + } + + static __typeInfo = $.registerStructType( + 'test.HookPointee', + new HookPointee(), + [], + HookPointee, + [], + ) +} + +class HookHolder { + public _fields = { + Hook: $.varRef(null), + } + + static __typeInfo = $.registerStructType( + 'test.HookHolder', + new HookHolder(), + [], + HookHolder, + [ + { + name: 'Hook', + key: 'Hook', + type: { kind: $.TypeKind.Pointer, elemType: 'test.HookPointee' }, + tag: 'json:"hook"', + }, + ], + ) +} + class Holder { public _fields = { Value: $.varRef(null), @@ -527,6 +567,32 @@ describe('encoding/json override', () => { ) }) + it('allocates a nil pointer-to-struct field and invokes UnmarshalJSON on it, instead of decoding it field-by-field', () => { + const target = $.varRef(new HookHolder()) + const err = Unmarshal($.stringToBytes('{"hook":{"nested":true}}'), target) + + expect(err).toBeNull() + expect(target.value._fields.Hook.value).toBeInstanceOf(HookPointee) + expect(target.value._fields.Hook.value?.Text).toBe('{"nested":true}') + }) + + it('invokes UnmarshalJSON on a non-nil pointer-to-struct field in place, instead of replacing it with a freshly decoded instance', () => { + const holder = new HookHolder() + const existing = new HookPointee() + existing.Marker = 'orig' + holder._fields.Hook.value = existing + + const target = $.varRef(holder) + const err = Unmarshal($.stringToBytes('{"hook":{"nested":true}}'), target) + + expect(err).toBeNull() + // Go's encoding/json decodes into the existing pointee via its + // UnmarshalJSON hook, it never discards it for a fresh replacement. + expect(target.value._fields.Hook.value).toBe(existing) + expect(target.value._fields.Hook.value?.Marker).toBe('orig') + expect(target.value._fields.Hook.value?.Text).toBe('{"nested":true}') + }) + it('decodes pointer-to-struct values as unmarked pointees, matching *Struct not Struct by value', () => { const target = $.varRef(new Schema()) const err = Unmarshal( diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index 4cc9a6dd..0d670b3f 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1116,6 +1116,16 @@ function assignDecodedFieldValue( if ($.isPointerTypeInfo(info) && isPlainObject(decoded)) { const pointee = resolveTypeInfo(info.elemType) if (pointee !== null && $.isStructTypeInfo(pointee)) { + // A field that already holds a non-nil pointer whose pointee + // implements UnmarshalJSON must still go through that hook: Go's + // encoding/json always prefers a custom decoder over field-by-field + // population, even when the field is pre-populated rather than nil. + // The type-driven constructor below only sees a fresh instance it + // allocates itself, so it cannot see this pre-existing target. + if (unmarshalJSONTarget(target.value) !== null) { + assignDecodedValue(target, decoded, opts) + return + } target.value = decodeValueForType(decoded, info, opts) return } @@ -1190,6 +1200,21 @@ function decodeValueForType( return decoded } const inst = new pointee.ctor() + // A freshly-allocated pointee that implements UnmarshalJSON must be + // decoded through that hook rather than field-by-field, mirroring Go: + // encoding/json always prefers a custom decoder over the default + // struct decode, including for a pointer field/element it allocates + // itself. + const unmarshaler = unmarshalJSONTarget(inst) + if (unmarshaler !== null) { + const err = unmarshaler.UnmarshalJSON( + $.stringToBytes(JSON.stringify(decoded)), + ) + if (err !== null) { + throw err + } + return inst + } if (isStructValue(inst)) { assignStructFields(inst, decoded, opts) } From 34a0f56b4c4a9fe8e7f14e6f618dffc67036dc51 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 07:30:22 +0200 Subject: [PATCH 4/6] fix(encoding/json): honor json tags on anonymous struct Unmarshal targets Part of #142 (item 5). Stacked on #145 (fix/json-unmarshal-typed-containers): both need the __goType-spelling parser (goTypeDescriptor) and the type-driven decoder (decodeValueForType) that PR introduces, so this PR extends them rather than duplicating them. Please merge #145 first. Unmarshal only knew how to populate a target carrying struct-field runtime metadata (a registered, named struct type). An anonymous (inline, unnamed) struct target carries no such metadata: var loose struct { Name string `json:"name"` Age int `json:"age"` } json.Unmarshal([]byte(`{"name":"Ada","age":30}`), &loose) // loose.Name and loose.Age read back "" and 0 -- every field empty so it fell through to the untyped interface{} decode, which replaces the plain-object struct value with this override's Map representation entirely, dropping every field. Fix: an anonymous struct target still carries its Go type spelling (including field names, types, and json tags) via __goType, e.g. `*struct{Name string "json:\"name\""; Age int "json:\"age\""}`. Parses it into the same fieldMetadata shape a registered struct's fields use (parseAnonymousStructFields, splitting on the tag's Go-quoted string literal so a ';' inside a tag can't split a field early), then decodes each field through the existing per-field path (assignDecodedFieldValue/assignAnonymousStructFields) -- tags, nested slices/maps, and RawMessage fields included. Wired into both assignDecodedValue (a top-level anonymous-struct var) and decodeValueForType (an anonymous struct used as a map/slice element type, e.g. `map[string]struct{...}`). Extends gs/encoding/json/index.test.ts with two cases: a top-level anonymous struct target, and one used as a map value type. Both fail on the parent commit (empty fields / a Map instead of the plain object) and pass with this fix. Co-Authored-By: Claude Fable 5 Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 40 +++++++++ gs/encoding/json/index.ts | 155 +++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 88df9a78..78338f29 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -645,6 +645,46 @@ describe('encoding/json override', () => { expect($.is(width, propertyType)).toBe(false) }) + it('honors json tags on anonymous (inline, unnamed) struct targets', () => { + // The runtime boxes an Unmarshal target with no struct-field metadata of + // its own (an inline, unnamed struct type) with its type spelling via + // __goType, e.g. for: + // var loose struct { + // Name string `json:"name"` + // Age int `json:"age"` + // } + // json.Unmarshal(data, &loose) + const target = $.varRef<{ Name: string; Age: number }>({ + Name: '', + Age: 0, + }) + target.__goType = + '*struct{Name string "json:\\"name\\""; Age int "json:\\"age\\""}' + + const err = Unmarshal($.stringToBytes('{"name":"Ada","age":30}'), target) + expect(err).toBeNull() + expect(target.value.Name).toBe('Ada') + expect(target.value.Age).toBe(30) + }) + + it('honors json tags on an anonymous struct used as a map element type', () => { + // Same mechanism as the top-level case above, reached through + // decodeValueForType for a map[string]struct{...} spelling, e.g.: + // var loose map[string]struct { + // Name string `json:"name"` + // } + // json.Unmarshal(data, &loose) + const target = $.varRef | null>(null) + target.__goType = '*map[string]struct{Name string "json:\\"name\\""}' + + const err = Unmarshal( + $.stringToBytes('{"a":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value?.get('a')).toEqual({ Name: 'Ada' }) + }) + it('decodes json.RawMessage as a map and slice element type', () => { // The runtime boxes Unmarshal's `any` target with its Go type spelling // (__goType) at the call site; simulate that here the way $.interfaceValue diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index 0d670b3f..deed862a 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1023,6 +1023,20 @@ function assignDecodedValue( const spelling = unmarshalTargetGoType(target) if (spelling !== null) { const desc = goTypeDescriptor(spelling) + // An anonymous (inline, unnamed) struct target, e.g. `var loose + // struct{ Name string "json:\"name\"" }`, carries no _fields/registered + // type metadata of its own to decode against, so it needs its json + // tags read from the parsed __goType spelling instead. See + // assignAnonymousStructFields below. + const anonFields = anonymousStructFields(desc) + if ( + anonFields !== null && + isPlainObject(decoded) && + isPlainObject(target.value) + ) { + assignAnonymousStructFields(target.value, decoded, anonFields, opts) + return + } const info = resolveTypeInfo(desc) if (info !== null) { if ( @@ -1169,6 +1183,10 @@ function resolveTypeInfo(t: unknown): $.TypeInfo | null { // *Struct value as the struct instance itself for field access) — // without this, map/slice elements typed e.g. map[string]*Property // leaked plain objects whose Go field reads came back undefined. +// - Anonymous struct element: decodes to a plain object keyed by Go field +// name, honoring the field's own json tags (see +// assignAnonymousStructFields below) — the runtime's representation of +// an inline struct{...} value. // - Missing type info / interface{} elements: shape-driven decode via // decodeInterfaceValue, mirroring Go's json.Unmarshal-into-any. function decodeValueForType( @@ -1182,6 +1200,15 @@ function decodeValueForType( if (decoded === null || decoded === undefined) { return decoded } + const anonFields = anonymousStructFields(typeInfo) + if (anonFields !== null) { + if (!isPlainObject(decoded)) { + return decoded + } + const out: Record = {} + assignAnonymousStructFields(out, decoded, anonFields, opts) + return out + } const info = resolveTypeInfo(typeInfo) if (info === null || $.isInterfaceTypeInfo(info)) { return decodeInterfaceValue(decoded) @@ -1311,6 +1338,7 @@ function unmarshalTargetGoType(target: unknown): string | null { // function ever runs. // - 'json.RawMessage' RawMessage marker (isRawMessageType) // - {kind: Slice|Map, elemType} for []T / map[K]V +// - {anonymousStructFields} for inline struct{...} types // - the spelling itself for named types ($.getTypeByName resolves) // - null for interface{} and unparseable spellings // (shape-driven decode, same as before) @@ -1341,9 +1369,136 @@ function goTypeDescriptor(spelling: string): unknown { elemType: goTypeDescriptor(s.slice(keyEnd + 1)), } } + if (s.startsWith('struct{') && s.endsWith('}')) { + return { + anonymousStructFields: parseAnonymousStructFields( + s.slice('struct{'.length, -1), + ), + } + } return s } +// parseAnonymousStructFields parses the field list of an inline struct type +// spelling ("Name Type" or "Name Type \"tag\"" decls separated by ';') into +// fieldMetadata. Embedded fields (no explicit name) are skipped: there is no +// way to address their promoted keys from this spelling alone. +function parseAnonymousStructFields(body: string): fieldMetadata[] { + const fields: fieldMetadata[] = [] + for (const decl of splitGoFieldDecls(body)) { + const [spec, tag] = splitGoFieldTag(decl.trim()) + const space = spec.indexOf(' ') + if (space < 0) { + continue + } + const name = spec.slice(0, space) + fields.push({ + key: name, + name, + type: goTypeDescriptor(spec.slice(space + 1).trim()), + ...(tag !== undefined ? { tag } : {}), + }) + } + return fields +} + +// splitGoFieldDecls splits a struct-type field list on ';' at nesting depth +// zero, skipping string literals (tags) so a ';' inside a tag cannot split. +function splitGoFieldDecls(body: string): string[] { + const decls: string[] = [] + let depth = 0 + let inStr = false + let start = 0 + for (let i = 0; i < body.length; i++) { + const c = body[i] + if (inStr) { + if (c === '\\') { + i++ + } else if (c === '"') { + inStr = false + } + continue + } + if (c === '"') { + inStr = true + } else if (c === '{' || c === '[') { + depth++ + } else if (c === '}' || c === ']') { + depth-- + } else if (c === ';' && depth === 0) { + decls.push(body.slice(start, i)) + start = i + 1 + } + } + decls.push(body.slice(start)) + return decls +} + +// splitGoFieldTag splits one field decl into [name+type, unquoted tag?]. The +// tag is the trailing Go-quoted literal at depth zero (a nested inline struct +// type keeps its own tags inside its braces, at depth > 0). +function splitGoFieldTag(decl: string): [string, string | undefined] { + let depth = 0 + for (let i = 0; i < decl.length; i++) { + const c = decl[i] + if (c === '{' || c === '[') { + depth++ + } else if (c === '}' || c === ']') { + depth-- + } else if (c === '"' && depth === 0) { + return [decl.slice(0, i).trim(), unquoteGoString(decl.slice(i).trim())] + } + } + return [decl, undefined] +} + +// unquoteGoString unquotes a Go double-quoted string literal. strconv.Quote +// output is JSON-compatible for the escapes tags use (\" and \\); anything +// exotic falls back to a plain backslash strip. +function unquoteGoString(lit: string): string { + try { + return JSON.parse(lit) as string + } catch { + return lit.slice(1, -1).replace(/\\(.)/g, '$1') + } +} + +// anonymousStructFields returns the parsed field list when the descriptor +// came from an inline struct{...} spelling, else null. +function anonymousStructFields(desc: unknown): fieldMetadata[] | null { + if (desc === null || typeof desc !== 'object') { + return null + } + const fields = (desc as { anonymousStructFields?: unknown }) + .anonymousStructFields + return Array.isArray(fields) ? (fields as fieldMetadata[]) : null +} + +// assignAnonymousStructFields populates an anonymous-struct value (a plain +// JS object keyed by Go field name, the runtime's representation of an +// inline struct) from decoded JSON, honoring json tags and field types via +// the same per-field decode named-struct fields get +// (assignDecodedFieldValue). +function assignAnonymousStructFields( + target: Record, + decoded: Record, + fields: fieldMetadata[], + opts: decodeOptions, +): void { + for (const field of fields) { + const jsonName = jsonFieldName(field.name, field.tag) + if ( + jsonName === '' || + !Object.prototype.hasOwnProperty.call(decoded, jsonName) + ) { + continue + } + const ref = $.varRef(target[field.name]) + assignDecodedFieldValue(ref, decoded[jsonName], opts, field.type) + target[field.name] = ref.value + } +} + // matchingBracketIndex returns the index of the ']' matching the '[' at // `open`, or -1. Tracks nesting only — Go map key spellings carry no strings // or further brackets in the shapes goTypeDescriptor parses (map keys are From 0dba4def0cb34b57f770a432c673b9bdc2310c0c Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 07:46:11 +0200 Subject: [PATCH 5/6] fix(encoding/json): pointer targets and disallowUnknownFields for anonymous decode Addresses review feedback on #146: three related gaps left by the anonymous-struct decoder, all found by consistency-checking it against the pointer/disallowUnknownFields handling elsewhere in the file. 1. A top-level pointer-to-struct var, e.g. `var p *pkg.T` decoded via `json.Unmarshal(data, &p)`, fell through to the untyped decode and produced a Map instead of a real (unmarked) struct pointee. assignDecodedValue's type-driven dispatch now also matches Pointer TypeInfo, reusing decodeValueForType's existing (correctly unmarked) Pointer handling -- the same path pointer-typed fields and map/slice elements already went through. 2. assignAnonymousStructFields ignored opts.disallowUnknownFields, so Decoder.DisallowUnknownFields() correctly errored for named structs but silently accepted unknown keys on anonymous struct targets. Now checks decoded keys against the field list's json names the same way assignStructFields does for named structs. 3. A by-value named-struct field inside an anonymous struct (e.g. `struct{ Inner pkg.T "json:\"inner\"" }`) has no pre-existing instance for the generic decode path to populate into -- unlike a registered struct's fields, an anonymous struct's own fields start unset (the target object starts as {}). assignAnonymousStructFields now constructs such fields directly via decodeValueForType whenever the field's type resolves to a real, ctor-bearing struct type; an inline TypeInfo with no ctor (as used by struct fields elsewhere in this test file) is left on the existing generic path unchanged. Extends gs/encoding/json/index.test.ts with one case per gap. All three fail on the parent commit and pass with this fix, verified by temporarily reverting just the source change. Co-Authored-By: Claude Fable 5 Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 61 ++++++++++++++++++++++++++++++++++ gs/encoding/json/index.ts | 51 +++++++++++++++++++++++++--- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 78338f29..985f6a46 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -685,6 +685,67 @@ describe('encoding/json override', () => { expect(target.value?.get('a')).toEqual({ Name: 'Ada' }) }) + it('decodes a top-level pointer-to-struct var as an unmarked struct pointee', () => { + // `var p *test.Property; json.Unmarshal(data, &p)`: &p's own Go type is + // **test.Property (one star from p already being a pointer, one from + // &p); unmarshalTargetGoType strips only the outermost address-of star, + // leaving *test.Property, so this must go through the same Pointer + // handling as a pointer-typed field/element (see decodeValueForType's + // Pointer branch) rather than falling through to the untyped decode. + const target = $.varRef(null) + target.__goType = '**test.Property' + + const err = Unmarshal($.stringToBytes('{"type":"string"}'), target) + expect(err).toBeNull() + expect(target.value?._fields.Type.value).toBe('string') + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + const propertyPointerType: $.TypeInfo = { + kind: $.TypeKind.Pointer, + elemType: propertyType, + } + expect($.is(target.value, propertyPointerType)).toBe(true) + expect($.is(target.value, propertyType)).toBe(false) + }) + + it('honors disallowUnknownFields on anonymous struct targets', () => { + const target = $.varRef<{ Name: string }>({ Name: '' }) + target.__goType = '*struct{Name string "json:\\"name\\""}' + + const strictReader = bytes.NewBufferString('{"name":"Ada","extra":true}')! + const strictDecoder = NewDecoder(strictReader) + strictDecoder.DisallowUnknownFields() + + expect(strictDecoder.Decode(target)?.Error()).toBe( + 'json: unknown field "extra"', + ) + }) + + it('constructs a by-value named-struct field inside an anonymous struct target', () => { + // An anonymous struct's own fields start unset (the target object + // starts as {}), unlike a registered struct's fields, which are + // pre-initialized to a zero-value instance. A by-value named-struct + // field (Inner test.Property, no pointer) therefore has no pre-existing + // instance for the generic decode path to populate into and must be + // constructed directly. + const target = $.varRef<{ Inner: Property | undefined }>({ + Inner: undefined, + }) + target.__goType = '*struct{Inner test.Property "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"type":"string"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toBeInstanceOf(Property) + expect(target.value.Inner?._fields.Type.value).toBe('string') + + const propertyType = $.getTypeByName('test.Property') as $.TypeInfo + // A by-value struct field must match Property (value), not *Property. + expect($.is(target.value.Inner, propertyType)).toBe(true) + }) + it('decodes json.RawMessage as a map and slice element type', () => { // The runtime boxes Unmarshal's `any` target with its Go type spelling // (__goType) at the call site; simulate that here the way $.interfaceValue diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index deed862a..ed00637a 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1041,7 +1041,15 @@ function assignDecodedValue( if (info !== null) { if ( ($.isSliceTypeInfo(info) && Array.isArray(decoded)) || - ($.isMapTypeInfo(info) && isPlainObject(decoded)) + ($.isMapTypeInfo(info) && isPlainObject(decoded)) || + // A top-level *Struct var (e.g. `var p *pkg.T`) needs the same + // type-driven Pointer handling as a pointer-typed struct field or + // map/slice element (see decodeValueForType's Pointer branch): + // without it, decoding falls through to decodeInterfaceValue + // below and produces a Map/plain object instead of a real + // (unmarked) struct instance, breaking *T type assertions and + // pointer-receiver method-set checks on the decoded result. + ($.isPointerTypeInfo(info) && isPlainObject(decoded)) ) { target.value = decodeValueForType(decoded, desc, opts) return @@ -1476,8 +1484,8 @@ function anonymousStructFields(desc: unknown): fieldMetadata[] | null { // assignAnonymousStructFields populates an anonymous-struct value (a plain // JS object keyed by Go field name, the runtime's representation of an -// inline struct) from decoded JSON, honoring json tags and field types via -// the same per-field decode named-struct fields get +// inline struct) from decoded JSON, honoring json tags, disallowUnknownFields, +// and field types via the same per-field decode named-struct fields get // (assignDecodedFieldValue). function assignAnonymousStructFields( target: Record, @@ -1485,6 +1493,20 @@ function assignAnonymousStructFields( fields: fieldMetadata[], opts: decodeOptions, ): void { + if (opts.disallowUnknownFields) { + const knownNames = new Set() + for (const field of fields) { + const jsonName = jsonFieldName(field.name, field.tag) + if (jsonName !== '') { + knownNames.add(jsonName) + } + } + for (const key of Object.keys(decoded)) { + if (!knownNames.has(key)) { + throw $.newError(`json: unknown field "${key}"`) + } + } + } for (const field of fields) { const jsonName = jsonFieldName(field.name, field.tag) if ( @@ -1493,8 +1515,29 @@ function assignAnonymousStructFields( ) { continue } + // An anonymous struct's own fields start unset (the target object + // starts as {}), so a by-value named-struct field (e.g. + // `Inner SomeStruct`) has no pre-existing instance for + // assignDecodedFieldValue's generic path to populate into, unlike a + // registered struct's fields (pre-initialized to a zero-value instance + // at construction). Construct it directly via the type-driven decoder + // whenever the field's type resolves to a real, ctor-bearing struct + // type (a bare type-name spelling resolved through $.getTypeByName); + // an inline TypeInfo with no ctor keeps the existing generic path + // unchanged below. + const fieldValue = decoded[jsonName] + const fieldTypeInfo = resolveTypeInfo(field.type) + if ( + fieldTypeInfo !== null && + $.isStructTypeInfo(fieldTypeInfo) && + typeof fieldTypeInfo.ctor === 'function' && + isPlainObject(fieldValue) + ) { + target[field.name] = decodeValueForType(fieldValue, fieldTypeInfo, opts) + continue + } const ref = $.varRef(target[field.name]) - assignDecodedFieldValue(ref, decoded[jsonName], opts, field.type) + assignDecodedFieldValue(ref, fieldValue, opts, field.type) target[field.name] = ref.value } } From 16e16f4e80b66c70922a6830c7146f2bd6318978 Mon Sep 17 00:00:00 2001 From: Marco Christian Krenn Date: Fri, 3 Jul 2026 16:50:36 +0200 Subject: [PATCH 6/6] fix(encoding/json): decode nested anonymous struct fields, not a Map Addresses paralin's review on #146: a field whose own type is itself an inline struct{...} (or *struct{...}) still fell through to the generic interface{} decoder and came back as a Map keyed by the JSON tag instead of the anonymous struct representation keyed by the Go field name. Nested DisallowUnknownFields was skipped for the same reason. parseAnonymousStructFields stores such a field's type as another anonymous-struct descriptor (or a Pointer TypeInfo wrapping one), not a registered TypeInfo, so resolveTypeInfo(field.type) returns null for it, the same as for a missing type. assignAnonymousStructFields therefore left it on the generic assignDecodedFieldValue path, which treats a plain JSON object as an interface{} value (a Map). assignAnonymousStructFields now routes anonymous field descriptors (struct{...} directly, or *struct{...} via a new isAnonymousStructType helper) through decodeValueForType before the generic fallback. decodeValueForType recurses into assignAnonymousStructFields (or, for the pointer case, its Pointer branch falls through to the same anonymous decode for a non-struct-typeinfo pointee), so the field's own parsed json tags and disallowUnknownFields are honored. Extends gs/encoding/json/index.test.ts with three cases (nested struct{...} field, nested *struct{...} field, nested disallowUnknownFields). All three fail on the parent commit and pass with this fix. Verify: bun run typecheck && vitest run gs/encoding/json/index.test.ts Signed-off-by: Marco Christian Krenn --- gs/encoding/json/index.test.ts | 62 ++++++++++++++++++++++++++++++++++ gs/encoding/json/index.ts | 33 ++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/gs/encoding/json/index.test.ts b/gs/encoding/json/index.test.ts index 985f6a46..183f3893 100644 --- a/gs/encoding/json/index.test.ts +++ b/gs/encoding/json/index.test.ts @@ -746,6 +746,68 @@ describe('encoding/json override', () => { expect($.is(target.value.Inner, propertyType)).toBe(true) }) + it('decodes a nested anonymous struct field, not a generic Map', () => { + // A field whose own type is itself an inline struct{...} (e.g. + // struct { + // Inner struct { + // Name string `json:"name"` + // } `json:"inner"` + // } + // ) has no registered TypeInfo of its own: resolveTypeInfo(field.type) + // returns null for it, same as any other anonymous struct descriptor. + // Without routing it through decodeValueForType, it fell through to the + // generic interface{} decode and came back as a Map keyed by the JSON + // tag ("name") instead of the anonymous struct representation keyed by + // the Go field name ("Name"). + const target = $.varRef<{ Inner: { Name: string } | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toEqual({ Name: 'Ada' }) + }) + + it('decodes a pointer-to-anonymous-struct field, not a generic Map', () => { + // Same gap as above, one level of indirection further: a + // *struct{...} field. + const target = $.varRef<{ Inner: { Name: string } | null | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner *struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const err = Unmarshal( + $.stringToBytes('{"inner":{"name":"Ada"}}'), + target, + ) + expect(err).toBeNull() + expect(target.value.Inner).toEqual({ Name: 'Ada' }) + }) + + it('honors disallowUnknownFields on a nested anonymous struct field', () => { + const target = $.varRef<{ Inner: { Name: string } | undefined }>({ + Inner: undefined, + }) + target.__goType = + '*struct{Inner struct{Name string "json:\\"name\\""} "json:\\"inner\\""}' + + const strictReader = bytes.NewBufferString( + '{"inner":{"name":"Ada","extra":true}}', + )! + const strictDecoder = NewDecoder(strictReader) + strictDecoder.DisallowUnknownFields() + + expect(strictDecoder.Decode(target)?.Error()).toBe( + 'json: unknown field "extra"', + ) + }) + it('decodes json.RawMessage as a map and slice element type', () => { // The runtime boxes Unmarshal's `any` target with its Go type spelling // (__goType) at the call site; simulate that here the way $.interfaceValue diff --git a/gs/encoding/json/index.ts b/gs/encoding/json/index.ts index ed00637a..725f8b57 100644 --- a/gs/encoding/json/index.ts +++ b/gs/encoding/json/index.ts @@ -1536,12 +1536,45 @@ function assignAnonymousStructFields( target[field.name] = decodeValueForType(fieldValue, fieldTypeInfo, opts) continue } + // A field whose own type is itself an inline struct{...} or + // *struct{...} (e.g. `Inner struct{ Name string "json:\"name\"" }`) has + // no registered TypeInfo of its own — resolveTypeInfo(field.type) is + // null for it, same as it is for a missing type. Left on the generic + // path below, it decodes as a plain interface{} value (a Map keyed by + // JSON tag) instead of the anonymous struct representation (a plain + // object keyed by Go field name). Route it through decodeValueForType + // directly, which recurses into assignAnonymousStructFields (or, for + // *struct{...}, its Pointer branch, which falls through to the same + // anonymous decode for a non-struct-typeinfo pointee) and so honors the + // field's own parsed json tags and disallowUnknownFields. + if (isAnonymousStructType(field.type) && isPlainObject(fieldValue)) { + target[field.name] = decodeValueForType(fieldValue, field.type, opts) + continue + } const ref = $.varRef(target[field.name]) assignDecodedFieldValue(ref, fieldValue, opts, field.type) target[field.name] = ref.value } } +// isAnonymousStructType reports whether `t` (a field's parsed type +// descriptor, from parseAnonymousStructFields) denotes struct{...} or +// *struct{...} — the shapes decodeValueForType constructs via +// assignAnonymousStructFields rather than a registered struct's ctor, and +// so must not be left on the generic assignDecodedFieldValue path. +function isAnonymousStructType(t: unknown): boolean { + if (anonymousStructFields(t) !== null) { + return true + } + if (typeof t === 'object' && t !== null && 'kind' in t) { + const info = t as $.TypeInfo + if ($.isPointerTypeInfo(info)) { + return isAnonymousStructType(info.elemType) + } + } + return false +} + // matchingBracketIndex returns the index of the ']' matching the '[' at // `open`, or -1. Tracks nesting only — Go map key spellings carry no strings // or further brackets in the shapes goTypeDescriptor parses (map keys are