Repository navigation
Conversation
buildStructureArbitrary returned only the index signature dictionary when
a structure had one, so required and optional props were never generated
and every sample of e.g. { "[string]": "number", a: "number" } failed
the type it came from.
There was a problem hiding this comment.
Important
The declared-prop override is only sound when each declared prop's type is a subtype of the matching index value type. When it isn't, the generator emits values the index signature rejects — the same unsoundness class this PR sets out to fix. See the inline comment for reproductions.
Reviewed changes
- Index + declared props merge —
buildStructureArbitrarynow builds the index dictionary and the declared-props record for structures that have both, deleting index keys that collide with a declared prop before applying the declared value last (ark/fast-check/arktypeFastCheck.ts:107). - Regression tests — four new cases covering required/optional keys, regex index signatures, overlapping optional keys, and symbol index signatures (
ark/fast-check/__tests__/arktypeFastCheck.test.ts:282).
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
| // declared props win over index keys that happen to share their name | ||
| return fc | ||
| .tuple(indexArbitrary, buildObjectArbitrary(structure, ctx)) | ||
| .map(([indexed, declared]) => { | ||
| const result: Record<PropertyKey, unknown> = indexed | ||
| for (const prop of structure.props) delete result[prop.key] | ||
| return Object.assign(result, declared) |
There was a problem hiding this comment.
In arktype a key covered by an index signature must satisfy its declared type and the index value type — StructureNode.get intersects the two (value?.and(n.value) ?? n.value at ark/schema/structure/structure.ts:490). Treating the declared value as an override is only sound when the declared type is a subtype of the index value type; buildObjectArbitrary builds each prop from the un-intersected declared type, so this path emits values the index rejects. Reproduced on this commit: type({ "[string]": "number", a: "number|string" }) → counterexample {"a": ""} (a must be a number), and type({ "[string]": "0 < number < 10", a: "number.integer" }) → counterexample {"a": 0} (a must be positive).
Technical details
# Declared props are not intersected with matching index signatures
## Affected sites
- `ark/fast-check/arktypeFastCheck.ts:114` — the tuple/map overrides the index value with the declared value instead of intersecting the two.
- `ark/fast-check/arktypeFastCheck.ts:146` — `buildObjectArbitrary` builds each prop from `node.propsByKey[key].value` only, ignoring any index signature that covers the key.
## Reproduction (on 725f763f)
- `type({ "[string]": "number", a: "number|string" })` → `{"a": ""}` / `a must be a number`.
- `type({ "[string]": "0 < number < 10", a: "number.integer" })` → `{"a": 0}` / `a must be positive`.
## Required outcome
- For a declared key covered by an index signature, the generated value must satisfy both constraints. Build the prop arbitrary from the intersected root (mirroring `StructureNode.get`), or otherwise restrict the declared value to the matching index value type.
- Add a regression test where the declared prop type is wider than (not a subtype of) the index value type; the four added tests only cover the narrower/equal direction.
## Open questions for the human
- Until this is handled, should the builder throw for unsupported prop/index intersections rather than silently emitting invalid values?…hat match them A key covered by an index signature has to satisfy both its declared value and the index value, so build its arbitrary from their intersection. An optional key whose intersection is empty is left out.
|
Right, the declared value was used on its own. Fixed in 29c4436:
Added both reproductions as tests, plus an optional key disjoint from the index value ( |
buildStructureArbitrary returned the index signature's dictionary as soon
as a structure had one, so buildObjectArbitrary never ran and declared
props were dropped: every sample of
type({ "[string]": "number", a: "number" }) lacked `a`, failing the Type
it was built from (2/300 samples passed, only when the dictionary happened
to produce `a`).
Both arbitraries are now built and merged, declared props last. Index keys
sharing a declared key's name are deleted first, since an optional key the
record omits could otherwise keep a dictionary value its declared value
rejects ({ "[/^b$/]": "number", "b?": "number < 0" }). A declared key an
index signature also matches has to satisfy both values, so it is built
from its value intersected with each matching index value, the
intersection StructureNode.get uses; an optional key whose intersection is
never is left out of the record.
A prop that no index matches is still built through its prop node, so the
check rejecting required cyclic aliases still runs.
Ported by hand from #1705 by @lprnmns; the file had drifted from main.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

What
arkToArbitraryignored declared props on any object type that also has an index signature. Every generated value was missing the required keys, so the arbitrary produced only values that fail the type it was built from:The same happens with
{ "[/^x/]": "number", y: "string" }and{ "[symbol]": "number", a: "string" }.Why
In
buildStructureArbitrary, a structure withindexreturnedbuildIndexSignatureArbitrary(structure.index, ctx)right away, sobuildObjectArbitrary(which handlesrequired/optional) never ran.The fix builds both arbitraries and merges them, with the declared props applied last. Index keys that share a declared key's name are removed first: otherwise an optional key the record left out could keep a value from the index dictionary that the declared value would reject. For example,
{ "[string]": "number", "b?": "number < 0" }failed 3 of 20,000 samples without that step. Structures without props still return the index arbitrary unchanged.A declared key that an index signature also matches has to satisfy both values, so its arbitrary is built from the declared value intersected with each matching index value. This is the intersection
StructureNode.getuses, without the| undefinedthatgetadds for optional keys. An optional key whose intersection is empty is left out.Tests
In
ark/fast-check/__tests__/arktypeFastCheck.test.ts, the new tests all fail onmain:[/^b$/]index signature with an optionalb?key (this one fails if the overlapping keys aren't removed)Checklist:
mainbranchpnpm prCheckslocally. I didn't run the fullprChecks(build, benches, TS version matrix). I did runpnpm test(1817 passing),pnpm testFiles ark/fast-check/__tests__/arktypeFastCheck.test.tswith type checking (63 passing),pnpm typecheckRepo, and prettier and eslint on the changed files.