fix(builtin/type): a plain array never matches a map type#143
Conversation
matchesMapType and typeAssert's inline map-type branch accepted any
non-null object, including a plain JS Array. Object.entries() of an
array yields string-indexed pairs ([['0', v0], ['1', v1], ...]) that
pass the key/elem sampling used to structurally match a map type, so
a Go type switch listing `case map[string]any:` before `case []any:`
captured slices in the map branch. Ranging over the "map" then reads
Array.prototype.entries(), producing numeric-keyed output instead of
correct slice semantics — e.g.:
var v any = []any{"a", "b"}
switch x := v.(type) {
case map[string]any:
// wrongly taken; x behaves like {0: "a", 1: "b"}
case []any:
// never reached
}
A Go slice is never a map, so rejecting arrays (and Uint8Array, the
runtime's byte-slice representation) up front in both matchesMapType
and typeAssert's map branch is strictly correct and needs no further
type information.
Adds gs/builtin/type.test.ts covering is(), typeAssert(), and
typeSwitch() against map[string]any vs []any, including the empty-
value case (an empty map[string]any check previously matched an
empty array too, via the "empty map matches any map type" shortcut).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Marco Christian Krenn <marco.krenn@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a correctness bug in GoScript’s runtime type system where JavaScript arrays (and Uint8Array byte-slice values) could be structurally misclassified as Go map types during is()/typeAssert()/typeSwitch() evaluation, causing type switches to take the wrong branch.
Changes:
- Add an early guard in
matchesMapType()to rejectArrayandUint8Arraycandidates before map key/elem sampling. - Add the same guard to
typeAssert()’s inline map-assertion fast path so arrays fall through to the normalmatchesType()logic. - Add Vitest coverage to ensure slices don’t match
map[string]anyand thattypeSwitch()selects the slice case.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| gs/builtin/type.ts | Prevents arrays/byte-slices from being treated as map candidates during structural map matching and map assertions. |
| gs/builtin/type.test.ts | Adds regression tests for array-vs-map matching across is(), typeAssert(), and typeSwitch(). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| it('is(): a plain array does not match map[string]any', () => { | ||
| expect(is([1, 2, 3], mapStringAny)).toBe(false) | ||
| }) |
There was a problem hiding this comment.
Good catch — added a Uint8Array case alongside the plain-array is()/typeAssert() rejection tests in 15b6345.
| it('an empty array does not vacuously match an empty-map fast path', () => { | ||
| // matchesMapType/typeAssert's map branch special-cases zero entries as | ||
| // "matches any map type"; an empty slice must not hit that path. | ||
| expect(is([], mapStringAny)).toBe(false) | ||
| expect(is([], sliceAny)).toBe(true) | ||
| }) |
There was a problem hiding this comment.
Good catch — added an explicit empty-Uint8Array assertion to the empty-map-fast-path test in 15b6345.
Addresses review feedback on s4wave#143: the fix rejects Uint8Array (the runtime's []byte representation) from matching map types alongside plain arrays, but the original tests only covered plain JS arrays. Adds: - a Uint8Array case alongside the existing plain-array is()/typeAssert() rejection tests - an empty-Uint8Array assertion in the empty-map-fast-path test, since Object.entries() of an empty Uint8Array is also empty and could vacuously match a map type the same way an empty array could Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Marco Christian Krenn <marco.krenn@gmail.com>
paralin
left a comment
There was a problem hiding this comment.
Looks good. I verified the current head is still 15b63454, including the extra Uint8Array coverage added after Copilot's comments.
Rejecting Array and Uint8Array before the map structural matcher is the right fix: a Go slice or byte slice is never a map, so the runtime can reject those shapes before key/element sampling.
Focused check:
bun run vitest run gs/builtin/type.test.ts
# 7 passed
Part of #142 (item 7).
What
matchesMapTypeandtypeAssert's inline map-type branch accepted anynon-null object as a candidate map, including a plain JS
Array.Why
Object.entries()of an array yields string-indexed pairs(
[['0', v0], ['1', v1], ...]), which pass the key/elem sampling used tostructurally match a map type. So a Go type switch that lists
case map[string]any:beforecase []any:captures slices in the mapbranch, and ranging over the result calls
Array.prototype.entries(),producing numeric-keyed output instead of correct slice semantics:
A Go slice is never a map, so both
matchesMapTypeandtypeAssert's mapbranch now reject
Array/Uint8Arrayvalues up front before doing anykey/elem sampling — no further type information is needed to know a slice
isn't a map.
Fix
Two mechanical guards, added right after the existing null/object checks:
matchesMapType:if (Array.isArray(value) || value instanceof Uint8Array) return falsetypeAssert's inline map branch: same condition added to the guard so itfalls through to the normal
matchesTypepath (which now correctlyrejects it via the fixed
matchesMapType) instead of the special-casedmap-assertion logic.
Test
Added
gs/builtin/type.test.tscoveringis(),typeAssert(), andtypeSwitch()againstmap[string]anyvs[]any, plus the empty-valueedge case: an empty
map[string]anycheck previously matched an emptyarray too, via the "empty map matches any map type" shortcut in both
functions.
All cases fail on
masterand pass with this fix.Verify
7 passed.
Full suite:
bun run typecheck— cleanbun run test:js(typecheck + vitest) — 1278 passed, 4 skipped, 0 failedgo test ./...— all packages greenbun run lint:js— cleanFound while transpiling and running a real ~100k-LOC Go interpreter/parser
package through goscript.
🤖 Generated with Claude Code