docs: document structural transformations, apply required/partial to array elements - #1653
Conversation
There was a problem hiding this comment.
Important
The partial guard closes the silent predicate-drop for .partial(), but the identical hole remains open through .required() — see the inline comment on the guard.
Reviewed changes — .partial() on a type carrying a root predicate (via .narrow() or .filter(), including inside unions) now throws partial cannot be applied to a type with a predicate at construction instead of silently producing a partial type with the predicate stripped; a regression test pins the throw. I reproduced the original bug on the current head and confirmed the guard fires, the new test is load-bearing (it fails on the unfixed code), and existing nested-prop-predicate behavior is untouched as claimed.
ark/schema/roots/root.ts— new guard inapplyStructuralOperationthat throws a ParseError whenoperation === "partial"and the branch is an intersection carrying a root predicate.ark/type/__tests__/narrow.test.ts—rejects narrowed typesassertsUser.partial()throws with the exact message.
ℹ️ Breaking behavior not recorded in CHANGELOG
The Compatibility note in the PR description acknowledges that calls which previously returned a partial type now throw, but the unreleased section of ark/type/CHANGELOG.md (2.2.3) gets no entry. Existing fixes in that section credit contributors, so one line here keeps the break documented when 2.2.3 ships.
DeepSeek Flash (free via Pullfrog for OSS) | 𝕏
| // Partial makes the original required properties optional, so a root | ||
| // predicate cannot be preserved safely and must not be silently dropped. | ||
| if ( | ||
| operation === "partial" && | ||
| branch.hasKind("intersection") && | ||
| branch.inner.predicate | ||
| ) { | ||
| return throwParseError( | ||
| "partial cannot be applied to a type with a predicate" | ||
| ) | ||
| } |
There was a problem hiding this comment.
The partial guard closes one entrance to the silent predicate-drop, but .required() still hits it: type({ name: "string", email: "string" }).narrow(u => u.email.includes("@")).required() accepts { name: "Yi", email: "bad" } even though the narrowed type rejects it. required reaches the same drop because the intersection node rebuilt below at lines ~345-348 omits predicate for every structural operation, not just partial.
Technical details
# `.required()` still silently drops root predicates
## Affected sites
- ark/schema/roots/root.ts:345-348 — the rebuilt `intersection` node passes only `domain` + `structure`, dropping `branch.inner.predicate` for all structural ops; the new guard only special-cases `operation === "partial"`.
- Repro (verified on head): `type({ name: "string", email: "string" }).narrow(u => u.email.includes("@")).required()` accepts `{ name: "Yi", email: "bad" }` (original narrowed type rejects it).
## Required outcome
- No silent predicate loss for `required`. Prefer preserving the predicate for `required` — every value accepted by the required shape was already accepted by the pre-op shape, so the predicate stays well-defined — or reject it like `partial` does. At minimum, file the remaining hole before merge rather than leaving it implicit.
## Open questions for the human
- Is the `partial`-only line deliberate? The existing `merge` behavior is pinned by the "structural operation removes narrow" test (ark/type/__tests__/objects/props.test.ts:75), so the maintainer may want a uniform stance or per-operation policies (partial = reject, required = preserve, merge = drop, as today).Reverts the runtime changes from this branch so that `partial` and `required` continue to succeed on a type with a root predicate. Rejecting them would be inconsistent: `merge`, `pick`, `omit` and `map` go through the same `applyStructuralOperation` path and drop root predicates identically, and that is asserted intentionally by "structural operation removes narrow" in props.test.ts. Documenting the transformation is the fix for arktypeio#1596; see the following commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fqZ9YRuLZKnSBGedWLBF1
`merge`, `pick`, `omit`, `required`, `partial` and `map` don't reference the type they're applied to- they extract its properties and build a new object type from scratch. Anything attached to the object itself (a root narrow or filter, metadata, a length constraint) is therefore absent from the result, which is surprising enough that arktypeio#1596 and arktypeio#1468 were both filed against it. Nothing said so anywhere. Adds a "structural transformations" section to the objects docs with a warning callout, a runnable example of the narrow being dropped, and the workaround (reapply after transforming), plus a pointer from each affected section and a matching⚠️ line in the JSDoc for all six methods. Closes arktypeio#1596 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fqZ9YRuLZKnSBGedWLBF1
`applyStructuralOperation` rebuilt every branch as `{ domain: "object",
structure }`, which dropped the `proto: Array` basis while keeping the
sequence. `type("string[]").partial()` therefore inferred `string[]` but
accepted `{}` at runtime, and `type(["string", "number"]).partial()`
left both elements required.
`required` and `partial` are the fluent equivalents of TS's `Required`
and `Partial`, which are homomorphic and so preserve arrays and tuples.
They now do too:
- `SequenceNode.optionalize` moves prefix elements into optionals, and
`require` moves defaultables and optionals into prefix. Elements are
flattened rather than merged in place because `Sequence.Inner` fixes
the order prefix -> defaultables -> optionals, so a defaultable can't
keep its default once a preceding element becomes optional. When
there is no prefix the sequence is already fully optional, so
`optionalize` bails and the default survives.
- The rebuilt branch keeps a `proto: Array` basis when the transformed
structure has a sequence. Other operations still produce a plain
object, matching TS: `Pick<[string, number], "0">` is an object type,
and `pick`/`omit` already reject numeric tuple keys outright.
- Since these are the only operations that can be a no-op, a structure
that comes back unchanged now returns its original branch. That keeps
`type([]).partial()` as `[]` (an empty tuple is `exactLength: 0` with
no structure at all) rather than widening it to `unknown[]`.
Two deliberate divergences from TS, both because TS is compensating for
unsound index access rather than expressing optionality:
- TS unions variadic elements with `undefined` (`Partial<string[]>` is
`(string | undefined)[]`), as it does for index signature values.
ArkType's `partial` only changes whether an element must be present,
never the values it allows, so `type("string[]").partial()` is
unchanged- consistent with the index signature case already noted in
partial.test.ts, and avoiding a silent weakening of validation.
- TS folds postfix elements into the variadic, so `Partial<[string,
...number[], boolean]>` accepts `[true, true, true]`. An optional
element can't precede a postfix one in ArkType either, so the
sequence node's existing ParseError surfaces instead of widening.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018fqZ9YRuLZKnSBGedWLBF1
Adding shorn in arktypeio#1649 pushed the `pages` array past the print width, so `checkPrettier` has been failing on main since. Picked up here because CI checks the merge with main. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018fqZ9YRuLZKnSBGedWLBF1
|
Thanks for digging into this - I went a different direction than throwing, though. Both of your commits are reverted, so Docs: a Arrays: |

Problem
On current main, applying
.partial()to a type with a root.narrow()predicate silently removes that predicate. For example, a narrowed user type accepts{ email: "bad" }after.partial()and no longer enforces the original validation.Fix
Reject
.partial()when the object branch contains a root predicate. Since.partial()makes originally required properties optional, an arbitrary predicate cannot be preserved safely; the explicit ParseError prevents a silent validation change.Tests
pnpm testTyped --skipTypes --grep rejects ark/type/__tests__/narrow.test.ts— passedpnpm test— passed; 1,712 testspnpm lint— passedpnpm prChecks— passedCompatibility
Unrefined partial types and nested property predicates retain their existing behavior. Calls that previously created an unconstrained partial type from a root-predicate type now fail at construction with a clear ParseError instead of silently weakening validation.
Related issue
Fixes #1596