Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions ark/schema/roots/root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,18 @@ export abstract class BaseRoot<
// https://github.com/arktypeio/arktype/issues/1053
return branch

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


const structure = structureOf(branch)
if (!structure) {
throwParseError(
Expand Down
10 changes: 10 additions & 0 deletions ark/type/__tests__/narrow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,4 +261,14 @@ contextualize(() => {
attest(T.t).type.toString.snap("unknown")
attest(T.json).snap({ predicate: ["$ark.unknownPredicate854"] })
})

it("rejects narrowed types", () => {
const User = type({ name: "string", email: "string" }).narrow(user =>
user.email.includes("@")
)

attest(() => User.partial()).throws(
"partial cannot be applied to a type with a predicate"
)
})
})
Loading