Skip to content

docs: document structural transformations, apply required/partial to array elements - #1653

Merged
ssalbdivad merged 7 commits into
arktypeio:mainfrom
lprnmns:fix/reject-partial-on-narrowed-object
Sep 9, 2026
Merged

docs: document structural transformations, apply required/partial to array elements#1653
ssalbdivad merged 7 commits into
arktypeio:mainfrom
lprnmns:fix/reject-partial-on-narrowed-object

Conversation

@lprnmns

@lprnmns lprnmns commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

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 — passed
  • pnpm test — passed; 1,712 tests
  • pnpm lint — passed
  • pnpm prChecks — passed

Compatibility

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

@github-project-automation github-project-automation Bot moved this to To do in arktypeio Aug 31, 2026
@lprnmns
lprnmns marked this pull request as ready for review August 31, 2026 14:15

@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 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 in applyStructuralOperation that throws a ParseError when operation === "partial" and the branch is an intersection carrying a root predicate.
  • ark/type/__tests__/narrow.test.tsrejects narrowed types asserts User.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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

Comment thread ark/schema/roots/root.ts Outdated
Comment on lines +317 to +327
// 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"
)
}

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.

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).

lprnmns and others added 3 commits August 31, 2026 20:29
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
@ssalbdivad ssalbdivad changed the title fix(schema): reject partial on narrowed types docs: explain that object structural methods are transformations Sep 9, 2026
`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
@ssalbdivad ssalbdivad changed the title docs: explain that object structural methods are transformations docs: structural methods return a new object; required/partial parallel TS on arrays Sep 9, 2026
ssalbdivad and others added 2 commits September 9, 2026 17:20
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
@ssalbdivad ssalbdivad changed the title docs: structural methods return a new object; required/partial parallel TS on arrays fix(schema): reject partial on narrowed types Sep 9, 2026
@ssalbdivad ssalbdivad changed the title fix(schema): reject partial on narrowed types docs: document structural transformations, apply required/partial to array elements Sep 9, 2026
@ssalbdivad

ssalbdivad commented Sep 9, 2026

Copy link
Copy Markdown
Member

Thanks for digging into this - partial quietly dropping a root narrow is a real problem and it's the kind of thing that's easy to miss until it bites someone in production, so I appreciate you chasing it down with a repro and a test.

I went a different direction than throwing, though. merge, pick, omit, required and map all drop a root predicate through the same path, because each returns a new object built from the properties it extracts - rejecting two of the six would be inconsistent. So I've documented the behavior instead and left the description above as you wrote it. Here's what's on the branch now.

Both of your commits are reverted, so partial and required still succeed on a narrowed type.

Docs: a structural transformations section in the objects docs, linked from the merge, pick / omit, required / partial and map sections, plus a matching JSDoc note on all six methods.

Arrays: required and partial now preserve an array or tuple base and apply to its elements - type(["string", "number"]).partial() is [string?, number?], and type("string[]").partial() no longer accepts {}.

@ssalbdivad
ssalbdivad merged commit 8eeb460 into arktypeio:main Sep 9, 2026
6 checks passed
@github-project-automation github-project-automation Bot moved this from To do to Done (merged or closed) in arktypeio Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done (merged or closed)

Development

Successfully merging this pull request may close these issues.

.partial() silently drops .narrow() predicate (similar to #1468)

2 participants