Skip to content

fix(fast-check): keep declared props on objects with an index signature - #1705

Open
lprnmns wants to merge 2 commits into
arktypeio:mainfrom
lprnmns:fix/fast-check-index-signature-props
Open

lprnmns wants to merge 2 commits into
arktypeio:mainfrom
lprnmns:fix/fast-check-index-signature-props

Conversation

@lprnmns

@lprnmns lprnmns commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What

arkToArbitrary ignored 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:

const T = type({ "[string]": "number", a: "number" })
fc.sample(arkToArbitrary(T), 300).filter(v => !T.allows(v)).length // 300, a is never generated

The same happens with { "[/^x/]": "number", y: "string" } and { "[symbol]": "number", a: "string" }.

Why

In buildStructureArbitrary, a structure with index returned buildIndexSignatureArbitrary(structure.index, ctx) right away, so buildObjectArbitrary (which handles required/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.get uses, without the | undefined that get adds 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 on main:

  • an index signature with a required key
  • a regex index signature with required and optional keys
  • a symbol index signature with a string key
  • a [/^b$/] index signature with an optional b? key (this one fails if the overlapping keys aren't removed)
  • declared values wider than, overlapping with, and disjoint from (optional) the index value

Checklist:

  • Code is up-to-date with the main branch
  • You've successfully run pnpm prChecks locally. I didn't run the full prChecks (build, benches, TS version matrix). I did run pnpm test (1817 passing), pnpm testFiles ark/fast-check/__tests__/arktypeFastCheck.test.ts with type checking (63 passing), pnpm typecheckRepo, and prettier and eslint on the changed files.
  • There are new or updated unit tests validating the change

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — buildStructureArbitrary now 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).

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Comment on lines +113 to +119
// 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@lprnmns

lprnmns commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Right, the declared value was used on its own. Fixed in 29c4436:

  • When a structure has index signatures, each declared prop's arbitrary is now built from its value intersected with every index value whose signature matches the key, the same intersection StructureNode.get uses. I didn't call get directly because it adds undefined for optional keys, and the generated explicit undefined is then rejected.
  • If that intersection is empty, the key is left out. This only happens for optional keys, since a required key with an empty intersection already fails when the type is created.

Added both reproductions as tests, plus an optional key disjoint from the index value ({ "[string]": "number", "a?": "string" }). All three fail on the previous commit. I also sampled 200 values each from 1184 combinations of index signature (string, regex, symbol) × index value × required/optional declared key × declared value: every value passes its type, and none of the combinations throw. pnpm test (1817 passing), the typed fast-check tests (63 passing), typecheckRepo, prettier and eslint pass.

ssalbdivad added a commit that referenced this pull request Oct 22, 2026
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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: To do

Development

Successfully merging this pull request may close these issues.

1 participant