diff --git a/ark/docs/content/docs/ecosystem/meta.json b/ark/docs/content/docs/ecosystem/meta.json index 7df96ea848..a0c8991c43 100644 --- a/ark/docs/content/docs/ecosystem/meta.json +++ b/ark/docs/content/docs/ecosystem/meta.json @@ -1,5 +1,9 @@ { "title": "Ecosystem", "icon": "New", - "pages": ["[ArkEnv](/docs/ecosystem#arkenv)", "[DRZL](/docs/ecosystem#drzl)", "[shorn](/docs/ecosystem#shorn)"] + "pages": [ + "[ArkEnv](/docs/ecosystem#arkenv)", + "[DRZL](/docs/ecosystem#drzl)", + "[shorn](/docs/ecosystem#shorn)" + ] } diff --git a/ark/docs/content/docs/objects/index.mdx b/ark/docs/content/docs/objects/index.mdx index 7d32fdd21b..a5be37c46d 100644 --- a/ark/docs/content/docs/objects/index.mdx +++ b/ark/docs/content/docs/objects/index.mdx @@ -292,6 +292,8 @@ However, if a key appears in both the base and merged objects, the base value wi Spreading bypasses a lot of the behavioral complexity and computational overhead of an intersection and should be the preferred method of combining property sets. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `merge` returns a new object built from the properties it extracts, so a root narrow or metadata is not carried over. + @@ -562,6 +564,42 @@ Not your cup of tea? No worries- the inferred types and errors you'll see in edi string-embedded index access 🤓 +### structural transformations [#properties-structural] + +`merge`, `pick`, `omit`, `required`, `partial` and `map` each return a **new object built from the properties** of the `Type` they're applied to rather than referencing it. + + + + Anything attached to the object itself is not part of that result- most importantly a root [narrow](/docs/expressions#narrow) or [filter](/docs/expressions#filter), but also [metadata](/docs/configuration#metadata) like `.describe(...)` and constraints like `.atLeastLength(...)`. Constraints on individual properties are unaffected. + + ```ts + const User = type({ + name: "string", + email: "string" + }).narrow(user => user.email.includes("@")) + + // Type<{ name?: string; email?: string }>- the narrow is not a property + const PartialUser = User.partial() + + // no error, though User itself would have rejected this + const out = PartialUser({ email: "not an email" }) + ``` + + Reapply anything you still need to the result: + + ```ts + const PartialUser = type({ + name: "string", + email: "string" + }) + .partial() + .narrow(user => user.email?.includes("@") ?? true) + ``` + + A root [morph](/docs/expressions#pipe) is the exception. Since it can't be reduced to a set of properties, transforming a `Type` like `type({ name: "string" }).pipe(user => user)` throws a `ParseError` rather than discarding it. + + + ### pick / omit [#properties-pick-omit] Extract or exclude specific properties from an object Type: @@ -582,6 +620,8 @@ const WithoutEmail = User.omit("email") These are also available as [generic keywords](/docs/generics): `Pick(User, "name | email")`, `Omit(User, "email")`. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `pick` and `omit` return a new object built from the properties they extract, so a root narrow or metadata is not carried over. + ### required / partial [#properties-required-partial] Make all named properties required or optional: @@ -607,6 +647,26 @@ const PartialConfig = Config.partial() These are also available as [generic keywords](/docs/generics): `Required(User)`, `Partial(Config)`. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `required` and `partial` return a new object built from the properties they extract, so a root narrow or metadata is not carried over. + + + + Like the homomorphic mapped types they're named for, `required` and `partial` preserve an array or tuple base and apply to its elements: + + ```ts + // Type<[string?, number?]> + const PartialPair = type(["string", "number"]).partial() + + // Type<[string, number]> + const RequiredPair = type(["string", "number?"]).required() + ``` + + They only ever change whether an element must be **present**, never the values it allows. TS additionally unions variadic elements and index signature values with `undefined`, since it has no modifier to mark either optional. ArkType doesn't, so `type("string[]").partial()` is still `string[]`. + + A postfix element can't be optional- `[string?, ...number[], boolean]` is as unrepresentable in ArkType as it is in TS. `partial` therefore throws a `ParseError` on a tuple like `[string, ...number[], boolean]` rather than widening every element the way TS's `Partial` does. + + + ### readonly [#properties-readonly] Mark all properties as readonly (type-level only, no runtime effect): @@ -624,6 +684,8 @@ const Frozen = type({ Transform the properties of an object Type using a mapping function. The mapper receives a prop entry with `key`, `value`, and `kind` (`"required"` or `"optional"`). Return a `{ key, value }` object (optionally with `kind`) to transform, or an empty array `[]` to remove the property. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `map` returns a new object built from the properties it extracts, so a root narrow or metadata is not carried over. + ```ts // @noErrors const User = type({ diff --git a/ark/docs/content/docs/objects/properties/meta.json b/ark/docs/content/docs/objects/properties/meta.json index 1bc299a5f9..6ae14aeff9 100644 --- a/ark/docs/content/docs/objects/properties/meta.json +++ b/ark/docs/content/docs/objects/properties/meta.json @@ -9,6 +9,7 @@ "[spread](/docs/objects#properties-spread)", "[keyof](/docs/objects#properties-keyof)", "[get](/docs/objects#properties-get)", + "[structural transformations](/docs/objects#properties-structural)", "[pick / omit](/docs/objects#properties-pick-omit)", "[required / partial](/docs/objects#properties-required-partial)", "[readonly](/docs/objects#properties-readonly)", diff --git a/ark/docs/public/llms.txt b/ark/docs/public/llms.txt index eb8914f22b..fa36300fde 100644 --- a/ark/docs/public/llms.txt +++ b/ark/docs/public/llms.txt @@ -549,10 +549,7 @@ These selectors can also be used to [target specific references for configuratio const User = type({ name: "string", age: "number" }) // add the description to all domain nodes -const configured = User.configure( - { description: "a special string" }, - "domain" -) +const configured = User.configure({ description: "a special string" }, "domain") configured.get("name").description // "a special string" configured.get("age").description // "a special string" @@ -1596,6 +1593,49 @@ console.log(env.PORT) // (property) PORT: number console.log(env.NODE_ENV) // (property) NODE_ENV: "development" | "production" | "test" ``` +### DRZL + +[DRZL](https://use-drzl.github.io/drzl) is zero-friction codegen for Drizzle ORM, tailored for ArkType developers. It analyzes your Drizzle schemas and generates ArkType validation schemas, services, and routers—eliminating boilerplate and ensuring seamless type safety between your database and application layers. + +```ts +// @noErrors +// drzl.config.ts +import { defineConfig } from "@drzl/cli/config" + +export default defineConfig({ + schema: "src/db/schemas/index.ts", + outDir: "src/api", + generators: [ + // 1) ArkType validators + { kind: "arktype", path: "src/validators/arktype", schemaSuffix: "Schema" }, + + // 2) Routers (oRPC adapter), reusing ArkType schemas + { + kind: "orpc", + template: "@drzl/template-orpc-service", + includeRelations: true, + outputHeader: { enabled: true }, + validation: { + useShared: true, + library: "arktype", + importPath: "src/validators/arktype", + schemaSuffix: "Schema" + } + }, + // 3) Typed services (Drizzle-aware or stub) + { + kind: "service", + path: "src/services", + dataAccess: "drizzle", // or 'stub' + dbImportPath: "src/db/connection", + schemaImportPath: "src/db/schemas" + } + ] +}) +``` + +> For more details, see the [Getting Started guide](https://use-drzl.github.io/drzl/guide/getting-started.html). + --- title: Expressions @@ -4272,6 +4312,8 @@ However, if a key appears in both the base and merged objects, the base value wi Spreading bypasses a lot of the behavioral complexity and computational overhead of an intersection and should be the preferred method of combining property sets. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `merge` returns a new object built from the properties it extracts, so a root narrow or metadata is not carried over. + @@ -4542,6 +4584,42 @@ Not your cup of tea? No worries- the inferred types and errors you'll see in edi string-embedded index access 🤓 +### structural transformations [#properties-structural] + +`merge`, `pick`, `omit`, `required`, `partial` and `map` each return a **new object built from the properties** of the `Type` they're applied to rather than referencing it. + + + + Anything attached to the object itself is not part of that result- most importantly a root [narrow](/docs/expressions#narrow) or [filter](/docs/expressions#filter), but also [metadata](/docs/configuration#metadata) like `.describe(...)` and constraints like `.atLeastLength(...)`. Constraints on individual properties are unaffected. + + ```ts + const User = type({ + name: "string", + email: "string" + }).narrow(user => user.email.includes("@")) + + // Type<{ name?: string; email?: string }>- the narrow is not a property + const PartialUser = User.partial() + + // no error, though User itself would have rejected this + const out = PartialUser({ email: "not an email" }) + ``` + + Reapply anything you still need to the result: + + ```ts + const PartialUser = type({ + name: "string", + email: "string" + }) + .partial() + .narrow(user => user.email?.includes("@") ?? true) + ``` + + A root [morph](/docs/expressions#pipe) is the exception. Since it can't be reduced to a set of properties, transforming a `Type` like `type({ name: "string" }).pipe(user => user)` throws a `ParseError` rather than discarding it. + + + ### pick / omit [#properties-pick-omit] Extract or exclude specific properties from an object Type: @@ -4562,6 +4640,8 @@ const WithoutEmail = User.omit("email") These are also available as [generic keywords](/docs/generics): `Pick(User, "name | email")`, `Omit(User, "email")`. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `pick` and `omit` return a new object built from the properties they extract, so a root narrow or metadata is not carried over. + ### required / partial [#properties-required-partial] Make all named properties required or optional: @@ -4587,6 +4667,26 @@ const PartialConfig = Config.partial() These are also available as [generic keywords](/docs/generics): `Required(User)`, `Partial(Config)`. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `required` and `partial` return a new object built from the properties they extract, so a root narrow or metadata is not carried over. + + + + Like the homomorphic mapped types they're named for, `required` and `partial` preserve an array or tuple base and apply to its elements: + + ```ts + // Type<[string?, number?]> + const PartialPair = type(["string", "number"]).partial() + + // Type<[string, number]> + const RequiredPair = type(["string", "number?"]).required() + ``` + + They only ever change whether an element must be **present**, never the values it allows. TS additionally unions variadic elements and index signature values with `undefined`, since it has no modifier to mark either optional. ArkType doesn't, so `type("string[]").partial()` is still `string[]`. + + A postfix element can't be optional- `[string?, ...number[], boolean]` is as unrepresentable in ArkType as it is in TS. `partial` therefore throws a `ParseError` on a tuple like `[string, ...number[], boolean]` rather than widening every element the way TS's `Partial` does. + + + ### readonly [#properties-readonly] Mark all properties as readonly (type-level only, no runtime effect): @@ -4604,6 +4704,8 @@ const Frozen = type({ Transform the properties of an object Type using a mapping function. The mapper receives a prop entry with `key`, `value`, and `kind` (`"required"` or `"optional"`). Return a `{ key, value }` object (optionally with `kind`) to transform, or an empty array `[]` to remove the property. +Like ArkType's other [structural transformations](/docs/objects#properties-structural), `map` returns a new object built from the properties it extracts, so a root narrow or metadata is not carried over. + ```ts // @noErrors const User = type({ @@ -4626,7 +4728,7 @@ The `props` getter returns an array of property descriptors for introspection: const User = type({ name: "string", "age?": "number", - "role": "'admin' | 'user' = 'user'" + role: "'admin' | 'user' = 'user'" }) for (const prop of User.props) { diff --git a/ark/schema/roots/root.ts b/ark/schema/roots/root.ts index e14557ea80..9e74e7a174 100644 --- a/ark/schema/roots/root.ts +++ b/ark/schema/roots/root.ts @@ -330,9 +330,24 @@ export abstract class BaseRoot< : operation === "partial" ? "optionalize" : operation + const transformed = structure[structuralMethodName]( + ...(args as [never]) + ) as Structure.Node + + // like the mapped types they're named for, `required` and `partial` are + // homomorphic, preserving an array or tuple base + const isHomomorphic = operation === "required" || operation === "partial" + + // they are also the only operations that can be a no-op (e.g. on an + // array with no fixed elements), in which case the original branch is + // preserved rather than reduced to a new object + if (isHomomorphic && transformed.equals(structure)) return branch + return this.$.node("intersection", { - domain: "object", - structure: structure[structuralMethodName](...(args as [never])) + ...(isHomomorphic && transformed.sequence ? + { proto: Array } + : { domain: "object" }), + structure: transformed }) }) } diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 527bdbc95b..d1f94a642d 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -393,6 +393,27 @@ export class SequenceNode extends BaseConstraint { registeredReference(this.defaultValueMorphs) : undefined + optionalize(): SequenceNode { + const { prefix, defaultables, ...inner } = this.inner + // without a prefix, every element is already optional. bailing here + // preserves defaultables, which would otherwise have to be flattened + // into optionals to maintain their position relative to the prefix. + if (!prefix) return this + + return this.$.node("sequence", { + ...inner, + optionals: conflatenate(prefix, this.defaultablesAndOptionals) + }) + } + + require(): SequenceNode { + const { defaultables, optionals, ...inner } = this.inner + return this.$.node("sequence", { + ...inner, + prefix: conflatenate(this.prefix, this.defaultablesAndOptionals) + }) + } + protected elementAtIndex(data: array, index: number): SequenceElement { if (index < this.prevariadic.length) return this.tuple[index] const firstPostfixIndex = data.length - this.postfixLength diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index a67c026eca..fb36b151ef 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -567,6 +567,7 @@ export class StructureNode extends BaseConstraint { const { required, ...inner } = this.inner return this.$.node("structure", { ...inner, + ...(inner.sequence ? { sequence: inner.sequence.optionalize() } : {}), optional: this.props.map(prop => prop.hasKind("required") ? this.$.node("optional", prop.inner) : prop ) @@ -577,6 +578,7 @@ export class StructureNode extends BaseConstraint { const { optional, ...inner } = this.inner return this.$.node("structure", { ...inner, + ...(inner.sequence ? { sequence: inner.sequence.require() } : {}), required: this.props.map(prop => prop.hasKind("optional") ? { diff --git a/ark/type/__tests__/keywords/partial.test.ts b/ark/type/__tests__/keywords/partial.test.ts index 84f00e0dde..0e92a4f374 100644 --- a/ark/type/__tests__/keywords/partial.test.ts +++ b/ark/type/__tests__/keywords/partial.test.ts @@ -35,4 +35,44 @@ contextualize(() => { attest(T.expression).snap("{ [string]: number, bar?: 1, foo?: 1 }") }) + + it("tuple", () => { + const T = type(["string", "number"]).partial() + + attest<[string?, number?]>(T.t) + attest(T.expression).snap("[string?, number?]") + attest(T([])).equals([]) + attest(T(["foo"])).equals(["foo"]) + attest(T({}).toString()).snap("must be an array (was object)") + }) + + it("array is unaffected", () => { + // like the index signature above, TS unions a variadic element with + // undefined since it has no way to represent an optional one. in + // ArkType, optionality is about presence rather than the values an + // element allows, so the type is unchanged. + const T = type("string[]").partial() + + attest<(string | undefined)[]>(T.t) + attest(T.expression).snap("string[]") + attest(T([undefined]).toString()).snap( + "value at [0] must be a string (was undefined)" + ) + }) + + it("preserves defaultable elements", () => { + const T = type(["number = 5"]).partial() + + attest(T.expression).snap("[number = 5]") + }) + + it("postfix element", () => { + // TS folds postfix elements into the variadic here, which would allow + // values the original tuple never did + attest(() => + type(["string", "...", "number[]", "boolean"]).partial() + ).throws.snap( + "ParseError: A postfix required element cannot follow an optional or defaultable element" + ) + }) }) diff --git a/ark/type/__tests__/keywords/required.test.ts b/ark/type/__tests__/keywords/required.test.ts index ac2dd55764..dbd95b16ca 100644 --- a/ark/type/__tests__/keywords/required.test.ts +++ b/ark/type/__tests__/keywords/required.test.ts @@ -48,4 +48,19 @@ contextualize(() => { attest(T.expression).equals(Expected.expression) }) + + it("tuple", () => { + const T = type(["string", "number?"]).required() + + attest<[string, number]>(T.t) + attest(T.expression).snap("[string, number]") + attest(T(["foo"]).toString()).snap("must be exactly length 2 (was 1)") + }) + + it("empty tuple", () => { + const T = type([]).required() + + attest<[]>(T.t) + attest(T.expression).snap("[]") + }) }) diff --git a/ark/type/variants/object.ts b/ark/type/variants/object.ts index 2afd47e1e3..7c3bb5e18a 100644 --- a/ark/type/variants/object.ts +++ b/ark/type/variants/object.ts @@ -63,6 +63,11 @@ interface Type extends BaseType { /** * Create a copy of this `Type` with only the specified properties. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * * @example type({ foo: "string", bar: "number" }).pick("foo") // Type<{ foo: string }> */ pick = never>( @@ -76,6 +81,11 @@ interface Type extends BaseType { /** * Create a copy of this `Type` with all properties except the specified ones. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * * @example type({ foo: "string", bar: "number" }).omit("foo") // Type<{ bar: number }> */ omit = never>( @@ -89,6 +99,11 @@ interface Type extends BaseType { /** * Merge another `Type` definition, overriding properties of this `Type` with the duplicate keys. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * * @example type({ a: "1", b: "2" }).merge({ b: "3", c: "4" }) // Type<{ a: 1, b: 3, c: 4 }> */ merge< @@ -105,16 +120,42 @@ interface Type extends BaseType { /** * Create a copy of this `Type` with all properties required. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * + * 🔗 like TS's `Required`, an array or tuple base is preserved and + * applied to its elements, e.g. `type(["string", "number"]).required()` + * * @example const T = type({ "foo?"": "string" }).required() // Type<{ foo: string }> */ required(): Type<{ [k in keyof t]-?: t[k] }, $> /** * Create a copy of this `Type` with all properties optional. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * + * 🔗 like TS's `Partial`, an array or tuple base is preserved and + * applied to its elements, e.g. `type(["string", "number"]).partial()` + * * @example: const T = type({ foo: "string" }).optional() // Type<{ foo?: string }> */ partial(): Type<{ [k in keyof t]?: t[k] }, $> + /** + * Create a copy of this `Type` with each of its properties transformed by + * the provided function. Return `[]` to remove a property. + * + * ⚠️ returns a new object built from the properties it extracts, so a root + * narrow, filter or metadata on this `Type` is not carried over + * ({@link https://arktype.io/docs/objects#properties-structural | docs}) + * + * @example type({ foo: "string" }).map(prop => ({ key: prop.key, value: prop.value.or("null") })) // Type<{ foo: string | null }> + */ map< transformed extends listable, r = Type, $>