Skip to content

Commit

Permalink
docs: undeclared key definitions
Browse files Browse the repository at this point in the history
  • Loading branch information
ssalbdivad committed Jan 4, 2025
1 parent e38d965 commit cfca30d
Show file tree
Hide file tree
Showing 4 changed files with 207 additions and 121 deletions.
82 changes: 81 additions & 1 deletion ark/docs/content/docs/objects/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,87 @@ const myObject = type({

### undeclared [#properties-undeclared]

🚧 Coming soon ™️🚧
TypeScript's structural type system explicitly allows assigning objects with additional keys so long as all declared constraints are satisfied. ArkType mirrors this behavior by default because generally...

- Existing objects can be reused more often.
- Validation is much more efficient if you don't need to check for undeclared keys.
- Extra properties don't usually matter as long as those you've declared are satisfied.

However, sometimes the way you're using the object would make undeclared properties problematic. Even though they can't be reflected by TypeScript ([_yet_- please +1 the issue!](https://github.com/microsoft/TypeScript/issues/12936#issuecomment-1854411301)), ArkType _does_ support rejection or deletion of undeclared keys. This behavior can be defined for individual objects using the syntax below or [via configuration](/docs/configuration#undeclared) if you want to change the default across all objects.

<SyntaxTabs>
<SyntaxTab string>

```ts
// fail if any key other than "onlyAllowedKey" is present
const myClosedObject = type({
"+": "reject",
onlyAllowedKey: "string"
})

// delete all non-symbolic keys other than "onlyPreservedStringKey"
const myStrippedObject = type({
"+": "delete",
"[symbol]": "unknown",
onlyPreservedStringKey: "string"
})

// allow and preserve undeclared keys (the default behavior)
const myOpenObject = type({
// only specify "ignore" if you explicitly configured the default elsewhere
"+": "ignore",
nonexclusiveKey: "number"
})
```

</SyntaxTab>

<SyntaxTab fluent>

```ts
// fail if any key other than "onlyAllowedKey" is present
const myClosedObject = type({
onlyAllowedKey: "string"
}).onUndeclaredKey("reject")

// delete all non-symbolic keys other than "onlyPreservedStringKey"
const myStrippedObject = type({
"[symbol]": "unknown",
onlyPreservedStringKey: "string"
}).onUndeclaredKey("delete")

// allow and preserve undeclared keys (the default behavior)
const myOpenObject = type({
nonexclusiveKey: "number"
// only specify "ignore" if you explicitly configured the default elsewhere
}).onUndeclaredKey("ignore")

// there is also a method for altering nested objects recursively
const myDeeplyStrippedObject = type({
preserved: "string",
nested: {
preserved: "string"
}
}).onDeepUndeclaredKey("delete")
```

<Callout type="info" title="Prefer in-object syntax where possible">
Certain methods like `.onUndeclaredKey` or `.configure` require a full traversal and transformation of the node created by the initial `type` call.

<details>
<summary>**Learn to recognize when chaining creates unnecessary overhead**</summary>

Though they can be convenient if you need both variants of the type, most of the time you incur a significant performance cost instantiating your Type compared to the embedded syntax.

Though how a Type is defined will never affect validation performance, depending on your sensitivity to initialization, you may want to avoid chained methods that transform rather than compose their base type. Methods like `.or` and `.pipe` that create new `Types` that directly reference the original incur no such overhead, so feel free to use whichever syntax is more convenient for those operations.

</details>

</Callout>

</SyntaxTab>

</SyntaxTabs>

### merge [#properties-merge]

Expand Down
10 changes: 10 additions & 0 deletions ark/repo/scratch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,13 @@ import { ark, type } from "arktype"
flatMorph(ark.internal.resolutions, (k, v) => [k, v])

console.log(Object.keys(ark.internal.resolutions))

const customEven = type("number % 2", "@", {
expected: ctx => `custom expected ${ctx.description}`,
actual: data => `custom actual ${data}`,
problem: ctx => `custom problem ${ctx.expected} ${ctx.actual}`,
message: ctx => `custom message ${ctx.problem}`
})

// custom message custom problem custom expected a multiple of 2 custom actual 3
customEven(3)
116 changes: 116 additions & 0 deletions ark/type/__tests__/objects/onUndeclaredKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,120 @@ contextualize(() => {
domain: "object"
})
})

describe("traversal", () => {
const getExtraneousB = () => ({ a: "ok", b: "why?" })

it("loose by default", () => {
const t = type({
a: "string"
})

attest(t.json).equals(t.onUndeclaredKey("ignore").json)

const dataWithExtraneousB = getExtraneousB()
attest(t(dataWithExtraneousB)).equals(dataWithExtraneousB)
})

it("delete keys", () => {
const t = type({
a: "string"
}).onUndeclaredKey("delete")
attest(t({ a: "ok" })).equals({ a: "ok" })
attest(t(getExtraneousB())).snap({ a: "ok" })
})

it("applies shallowly", () => {
const t = type({
a: "string",
nested: {
a: "string"
}
}).onUndeclaredKey("delete")

attest(
t({
...getExtraneousB(),
nested: getExtraneousB()
})
).equals({ a: "ok", nested: { a: "ok", b: "why?" } as never })
})

it("can apply deeply", () => {
const t = type({
a: "string",
nested: {
a: "string"
}
}).onDeepUndeclaredKey("delete")

attest(
t({
...getExtraneousB(),
nested: getExtraneousB()
})
).equals({ a: "ok", nested: { a: "ok" } })
})

it("delete union key", () => {
const o = type([{ a: "string" }, "|", { b: "boolean" }]).onUndeclaredKey(
"delete"
)
// can distill to first branch
attest(o({ a: "to", z: "bra" })).snap({ a: "to" })
// can distill to second branch
attest(o({ b: true, c: false })).snap({ b: true })
// can handle missing keys
attest(o({ a: 2 }).toString()).snap(
"a must be a string (was a number) or b must be boolean (was missing)"
)
})

it("reject key", () => {
const t = type({
a: "string"
}).onUndeclaredKey("reject")
attest(t({ a: "ok" })).equals({ a: "ok" })
attest(t(getExtraneousB()).toString()).snap("b must be removed")
})

it("reject array key", () => {
const o = type({ "+": "reject", a: "string[]" })
attest(o({ a: ["shawn"] })).snap({ a: ["shawn"] })
attest(o({ a: [2] }).toString()).snap(
"a[0] must be a string (was a number)"
)
attest(o({ b: ["shawn"] }).toString())
.snap(`a must be an array (was missing)
b must be removed`)
})

it("reject key from union", () => {
const o = type([{ a: "string" }, "|", { b: "boolean" }]).onUndeclaredKey(
"reject"
)
attest(o({ a: 2, b: true }).toString()).snap(
"a must be a string or removed (was 2)"
)
})

it("can be configured", () => {
const types = type.module(
{
user: {
name: "string"
}
},
{
onUndeclaredKey: "delete"
}
)

attest(types.user.json).snap({
undeclared: "delete",
required: [{ key: "name", value: "string" }],
domain: "object"
})
})
})
})
120 changes: 0 additions & 120 deletions ark/type/__tests__/undeclaredKeys.test.ts

This file was deleted.

0 comments on commit cfca30d

Please sign in to comment.