diff --git a/ark/schema/structure/optional.ts b/ark/schema/structure/optional.ts index 4fc7594c2..b095067fd 100644 --- a/ark/schema/structure/optional.ts +++ b/ark/schema/structure/optional.ts @@ -1,4 +1,4 @@ -import { printable, throwParseError, unset } from "@ark/util" +import { hasDomain, printable, throwParseError, unset } from "@ark/util" import type { BaseRoot } from "../roots/root.ts" import type { declareNode } from "../shared/declare.ts" import { ArkErrors } from "../shared/errors.ts" @@ -100,18 +100,28 @@ export const assertDefaultValueAssignability = ( value: unknown, key = "" ): unknown => { - const out = node.in(value) + if (hasDomain(value, "object") && typeof value !== "function") + throwParseError(writeNonPrimitiveNonFunctionDefaultValueMessage(key)) + + const out = node.in(typeof value === "function" ? value() : value) if (out instanceof ArkErrors) throwParseError(writeUnassignableDefaultValueMessage(out.message, key)) + return value } export const writeUnassignableDefaultValueMessage = ( message: string, key = "" -): string => `Default value${key && ` for key ${key}`} ${message}` +): string => + `Default value${key && ` for key ${key}`} is not assignable: ${message}` export type writeUnassignableDefaultValueMessage< baseDef extends string, defaultValue extends string > = `Default value ${defaultValue} is not assignable to ${baseDef}` + +export const writeNonPrimitiveNonFunctionDefaultValueMessage = ( + key: string +): string => + `Default value${key && ` for key ${key}`} is not primitive so it should be specified as a function like () => ({my: 'object'})` diff --git a/ark/schema/structure/prop.ts b/ark/schema/structure/prop.ts index 43d5f9ea1..beaa8fde3 100644 --- a/ark/schema/structure/prop.ts +++ b/ark/schema/structure/prop.ts @@ -122,16 +122,11 @@ export abstract class BaseProp< return result } - private premorphedDefaultValue: unknown = - this.hasDefault() ? - this.value.includesMorph ? - this.value.assert(this.default) - : this.default - : undefined + private morphedDefaultFactory: () => unknown = this.getMorphedFactory() private defaultValueMorphs: Morph[] = [ data => { - data[this.key] = this.premorphedDefaultValue + data[this.key] = this.morphedDefaultFactory() return data } ] @@ -143,6 +138,18 @@ export abstract class BaseProp< hasDefault(): this is Optional.Node & { default: unknown } { return "default" in this } + getDefaultFactory(): () => unknown { + if (!this.hasDefault()) return () => undefined + if (typeof this.default === "function") return this.default as () => unknown + return () => this.default + } + getMorphedFactory(): () => unknown { + if (!this.hasDefault()) return () => undefined + const factory = this.getDefaultFactory() + return this.value.includesMorph ? + () => this.value.assert(factory()) + : factory + } traverseAllows: TraverseAllows = (data, ctx) => { if (this.key in data) { diff --git a/ark/type/__tests__/defaults.test.ts b/ark/type/__tests__/defaults.test.ts index 8b623fb00..280ad0614 100644 --- a/ark/type/__tests__/defaults.test.ts +++ b/ark/type/__tests__/defaults.test.ts @@ -1,5 +1,10 @@ import { attest, contextualize } from "@ark/attest" -import { writeUnassignableDefaultValueMessage } from "@ark/schema" +import { + registeredReference, + writeNonPrimitiveNonFunctionDefaultValueMessage, + writeUnassignableDefaultValueMessage +} from "@ark/schema" +import type { Primitive } from "@ark/util" import { scope, type } from "arktype" import type { Date } from "arktype/internal/keywords/constructors/Date.ts" import type { @@ -12,31 +17,56 @@ import { writeNonLiteralDefaultMessage } from "arktype/internal/parser/shift/ope contextualize(() => { describe("parsing and traversal", () => { it("base", () => { - const o = type({ foo: "string", bar: ["number", "=", 5] }) + const fn5 = () => 5 as const + const o = type({ + a: "string", + foo: "number = 5", + bar: ["number", "=", 5], + baz: ["number", "=", fn5] + }) + const fn5reg = registeredReference(fn5) // ensure type ast displays is exactly as expected - attest(o.t).type.toString.snap("{ foo: string; bar: defaultsTo<5> }") - attest<{ foo: string; bar?: number }>(o.inferIn) - attest<{ foo: string; bar: number }>(o.infer) + attest(o.t).type.toString.snap(`{ + a: string + foo: defaultsTo<5> + bar: defaultsTo<5> + baz: defaultsTo<() => 5> +}`) + attest<{ a: string; foo?: number; bar?: number; baz?: number }>(o.inferIn) + attest<{ a: string; foo: number; bar: number; baz: number }>(o.infer) attest(o.json).snap({ - required: [{ key: "foo", value: "string" }], + required: [{ key: "a", value: "string" }], optional: [ + { + default: fn5reg, + key: "baz", + value: { domain: "number", meta: { default: fn5reg } } + }, { default: 5, key: "bar", value: { domain: "number", meta: { default: 5 } } + }, + { + default: 5, + key: "foo", + value: { domain: "number", meta: { default: 5 } } } ], domain: "object" }) - attest(o({ foo: "", bar: 4 })).equals({ foo: "", bar: 4 }) - attest(o({ foo: "" })).equals({ foo: "", bar: 5 }) - attest(o({ bar: 4 }).toString()).snap( - "foo must be a string (was missing)" - ) - attest(o({ foo: "", bar: "" }).toString()).snap( + attest(o({ a: "", foo: 4, bar: 4, baz: 4 })).equals({ + a: "", + foo: 4, + bar: 4, + baz: 4 + }) + attest(o({ a: "" })).equals({ a: "", foo: 5, bar: 5, baz: 5 }) + attest(o({ bar: 4 }).toString()).snap("a must be a string (was missing)") + attest(o({ a: "", bar: "" }).toString()).snap( "bar must be a number (was a string)" ) }) @@ -51,7 +81,21 @@ contextualize(() => { "must be a number (was a string)" ) ) - .type.errors.snap("Type 'string' is not assignable to type 'number'.") + .type.errors.snap( + "Type 'string' is not assignable to type 'number | (() => number)'." + ) + attest(() => + // @ts-expect-error + type({ foo: "string", bar: ["number", "=", () => "5"] }) + ) + .throws( + writeUnassignableDefaultValueMessage( + "must be a number (was a string)" + ) + ) + .type.errors.snap( + "Type '() => string' is not assignable to type 'number | (() => number)'.Type '() => string' is not assignable to type '() => number'.Type 'string' is not assignable to type 'number'." + ) }) it("validated default in scope", () => { @@ -114,7 +158,7 @@ contextualize(() => { writeUnassignableDefaultValueMessage("must be a number (was boolean)") ) .type.errors( - "'boolean' is not assignable to parameter of type 'number'" + "'boolean' is not assignable to parameter of type 'number | (() => number)'" ) }) @@ -133,7 +177,7 @@ contextualize(() => { writeUnassignableDefaultValueMessage("must be a number (was boolean)") ) .type.errors( - "'boolean' is not assignable to parameter of type 'number'" + "'boolean' is not assignable to parameter of type 'number | (() => number)'" ) }) @@ -212,14 +256,23 @@ contextualize(() => { const out = t.assert({}) // pass the same date instance back - const expected = type({ key: ["Date", "=", out.key] }) + const expected = type({ key: ["Date", "=", () => out.key] }) // we can't check expected here since the Date instance will not // have a narrowed literal type attest<{ key: InferredDefault> }>(t.t) - attest(t.json).equals(expected.json) + // attest(t.json).equals(expected.json) + }) + + it("Date is immutable", () => { + const t = type({ date: 'Date = d"1993-05-21"' }) + const v1 = t.assert({}) + const time = v1.date.getTime() + v1.date.setMilliseconds(123) + const v2 = t.assert({}) + attest(v2.date.getTime()).equals(time) }) it("true", () => { @@ -320,6 +373,176 @@ contextualize(() => { }) }) + describe("works properly with types", () => { + it("allows primitives and factories for anys", () => { + const fn = () => {} + const t = type({ + foo1: ["unknown", "=", true], + bar1: ["unknown", "=", () => [true]], + baz1: ["unknown", "=", () => fn], + foo2: ["unknown.any", "=", true], + bar2: ["unknown.any", "=", () => [true]], + baz2: ["unknown.any", "=", () => fn] + }) + const v = t.assert({}) + attest(v).snap({ + foo1: true, + bar1: [true], + baz1: fn, + foo2: true, + bar2: [true], + baz2: fn + }) + }) + it("disallows plain objects for anys", () => { + attest(() => { + // @ts-expect-error + type({ foo: ["unknown", "=", { foo: "bar" }] }) + }) + .throws("is not primitive") + .type.errors("'foo' does not exist in type '() => unknown'.") + attest(() => { + // @ts-expect-error + type({ foo: ["unknown.any", "=", { foo: "bar" }] }) + }) + .throws("is not primitive") + .type.errors("'foo' does not exist in type '() => any'.") + }) + + it("allows string sybtyping", () => { + type({ + foo: [/^foo/ as type.cast<`foo${string}`>, "=", "foobar"], + bar: [/bar$/ as type.cast<`${string}bar`>, "=", () => "foobar" as const] + }) + }) + + it("shows types plainly", () => { + attest( + // @ts-expect-error + () => type({ foo: ["number", "=", true] }) + ) + .throws() + .type.errors.snap( + "Type 'boolean' is not assignable to type 'number | (() => number)'." + ) + attest( + // @ts-expect-error + () => type({ foo: ["number[]", "=", true] }) + ) + .throws() + .type.errors.snap( + "Type 'boolean' is not assignable to type '() => number[]'." + ) + attest( + // @ts-expect-error + () => type({ foo: [{ bar: "false" }, "=", true] }) + ) + .throws() + .type.errors.snap( + "Type 'boolean' is not assignable to type '() => { bar: false; }'." + ) + attest(() => + type({ + // @ts-expect-error + foo: [/foo/ as type.cast<"string" & { foo: true }>, "=", true] + }) + ) + .throws() + .type.errors.snap( + "Type 'true' is not assignable to type '(\"string\" & { foo: true; }) | (() => \"string\" & { foo: true; })'." + ) + attest( + // @ts-expect-error + () => type({ foo: [["number[]", "|", "string"], "=", true] }) + ) + .throws() + .type.errors.snap( + "Type 'boolean' is not assignable to type 'string | (() => string | number[])'." + ) + attest( + // @ts-expect-error + () => type(["number[]", "|", "string"], "=", true) + ) + .throws() + .type.errors.snap( + "Argument of type 'boolean' is not assignable to parameter of type 'string | (() => string | number[])'." + ) + // should not cause "instantiation is excessively deep" + attest( + // @ts-expect-error + () => type("number[]", "|", "string").default(true) + ) + .throws() + .type.errors( + "not assignable to parameter of type 'string | (() => string | number[])'." + ) + // should not cause "instantiation is excessively deep" + attest( + // @ts-expect-error + () => type("number[]", "|", "string").default(() => true) + ) + .throws() + .type.errors( + "not assignable to parameter of type 'string | (() => string | number[])'." + ) + }) + + it("uses input type for morphs", () => { + // @ts-expect-error + attest(() => type({ foo: ["string.numeric.parse = true"] })) + .throws("must be a string (was boolean)") + .type.errors( + "Default value true is not assignable to string.numeric.parse" + ) + // @ts-expect-error + attest(() => type({ foo: ["string.numeric.parse", "=", true] })) + .throws("must be a string (was boolean)") + .type.errors.snap( + "Type 'boolean' is not assignable to type 'string | (() => string)'." + ) + // @ts-expect-error + attest(() => type({ foo: ["string.numeric.parse", "=", () => true] })) + .throws("must be a string (was boolean)") + .type.errors( + "Type '() => boolean' is not assignable to type 'string | (() => string)'." + ) + const numtos = type("number").pipe(s => `${s}`) + // @ts-expect-error + attest(() => type({ foo: [numtos, "=", true] })) + .throws("must be a number (was boolean)") + .type.errors( + "Type 'boolean' is not assignable to type 'number | (() => number)'." + ) + // @ts-expect-error + attest(() => type({ foo: [numtos, "=", () => true] })) + .throws("must be a number (was boolean)") + .type.errors( + "Type '() => boolean' is not assignable to type 'number | (() => number)'." + ) + + const f = type({ + foo1: "string.numeric.parse = '123'", + foo2: ["string.numeric.parse", "=", "123"], + foo3: ["string.numeric.parse", "=", () => "123"], + bar1: [numtos, "=", 123], + bar2: [numtos, "=", () => 123], + baz1: type(numtos, "=", 123), + baz2: type(numtos, "=", () => 123), + baz3: type(numtos).default(123) + }) + attest(f.assert({})).snap({ + foo1: 123, + foo2: 123, + foo3: 123, + bar1: "123", + bar2: "123", + baz1: "123", + baz2: "123", + baz3: "123" + }) + }) + }) + describe("intersection", () => { it("two optionals, one default", () => { const l = type({ bar: ["number", "=", 5] }) @@ -364,4 +587,173 @@ contextualize(() => { ) }) }) + + describe("factory functions", () => { + it("works in tuple", () => { + const t = type({ foo: ["string", "=", () => "bar"] }) + attest(t.assert({ foo: "bar" })).snap({ foo: "bar" }) + }) + + it("works in type tuple", () => { + const foo = type(["string", "=", () => "bar"]) + const t = type({ foo }) + attest(t.assert({ foo: "bar" })).snap({ foo: "bar" }) + }) + + it("works in type args", () => { + const foo = type("string", "=", () => "bar") + const t = type({ foo }) + attest(t.assert({ foo: "bar" })).snap({ foo: "bar" }) + }) + + it("checks the returned value", () => { + attest(() => { + // @ts-expect-error + type({ foo: ["number", "=", () => "bar"] }) + }).throws.snap( + "ParseError: Default value is not assignable: must be a number (was a string)" + ) + attest(() => { + // @ts-expect-error + type({ foo: ["number[]", "=", () => "bar"] }) + }).throws.snap( + "ParseError: Default value is not assignable: must be an array (was string)" + ) + attest(() => { + // @ts-expect-error + type({ foo: [{ a: "number" }, "=", () => ({ a: "bar" })] }) + }).throws.snap( + "ParseError: Default value is not assignable: a must be a number (was a string)" + ) + }) + + it("morphs the returned value", () => { + const t = type({ foo: ["string.numeric.parse", "=", () => "123"] }) + attest(t.assert({})).snap({ foo: 123 }) + }) + + it("only allows argless functions for factories", () => { + attest(() => { + // @ts-expect-error + type({ bar: ["Function", "=", class {}] }) + }) + .throws.snap( + "TypeError: Class constructors cannot be invoked without 'new'" + ) + .type.errors.snap( + "Type 'typeof (Anonymous class)' is not assignable to type '() => Function'.Type 'typeof (Anonymous class)' provides no match for the signature '(): Function'." + ) + attest(() => { + // @ts-expect-error + type({ bar: ["number", "=", (a: number) => 1] }) + }).type.errors.snap( + "Type '(a: number) => number' is not assignable to type 'number | (() => number)'.Type '(a: number) => number' is not assignable to type '() => number'.Target signature provides too few arguments. Expected 1 or more, but got 0." + ) + attest(() => { + type({ bar: ["number", "=", (a?: number) => 1] }) + }) + }) + + it("default factory may return different values", () => { + let i = 0 + const t = type({ bar: type("number[]").default(() => [++i]) }) + attest(t.assert({}).bar).snap([3]) + attest(t.assert({}).bar).snap([4]) + }) + + it("default function factory", () => { + let i = 0 + const t = type({ + // this requires explicit type argument + bar: type("Function").default<() => number>(() => { + const j = ++i + return () => j + }) + }) + attest(t.assert({}).bar()).snap(3) + attest(t.assert({}).bar()).snap(4) + }) + + it("allows union factory", () => { + let i = 0 + const t = type({ + foo: [["number", "|", "number[]"], "=", () => (i % 2 ? ++i : [++i])] + }) + attest(t.assert({})).snap({ foo: [3] }) + attest(t.assert({})).snap({ foo: 4 }) + }) + }) + + describe("works with factories", () => { + // it("default array in string", () => { + // const t = type({ bar: type("number[] = []") }) + // attest(t.assert({}).bar).snap([]) + // attest(t.assert({}).bar !== t.assert({}).bar) + // }) + it("default array", () => { + const t = type({ + foo: type("number[]").default(() => [1]), + bar: type("number[]") + .pipe(v => v.map(e => e.toString())) + .default(() => [1]) + }) + const v1 = t.assert({}), + v2 = t.assert({}) + attest(v1).snap({ foo: [1], bar: ["1"] }) + attest(v1.foo !== v2.foo) + }) + it("default array is checked", () => { + attest(() => { + // @ts-expect-error + type({ bar: type("number[]").default(() => ["a"]) }) + }).throws( + writeUnassignableDefaultValueMessage( + "value at [0] must be a number (was a string)" + ) + ) + attest(() => { + type({ + baz: type("number[]") + .pipe(v => v.map(e => e.toString())) + // @ts-expect-error + .default(() => ["a"]) + }) + }).throws( + writeUnassignableDefaultValueMessage( + "value at [0] must be a number (was a string)" + ) + ) + }) + it("default object", () => { + const t = type({ + foo: type({ "foo?": "string" }).default(() => ({})), + bar: type({ "foo?": "string" }).default(() => ({ foo: "foostr" })), + baz: type({ foo: "string = 'foostr'" }).default(() => ({})) + }) + const v1 = t.assert({}), + v2 = t.assert({}) + attest(v1).snap({ + foo: {}, + bar: { foo: "foostr" }, + baz: { foo: "foostr" } + }) + attest(v1.foo !== v2.foo) + }) + it("default object is checked", () => { + attest(() => { + // @ts-expect-error + type({ foo: type({ foo: "string" }).default({}) }) + }).throws(writeNonPrimitiveNonFunctionDefaultValueMessage("")) + attest(() => { + type({ + // @ts-expect-error + bar: type({ foo: "number" }).default(() => ({ foo: "foostr" })) + }) + }).throws( + writeUnassignableDefaultValueMessage( + "foo must be a number (was a string)" + ) + ) + }) + }) }) diff --git a/ark/type/keywords/inference.ts b/ark/type/keywords/inference.ts index da5b16c7e..9701c8d86 100644 --- a/ark/type/keywords/inference.ts +++ b/ark/type/keywords/inference.ts @@ -437,6 +437,13 @@ export type Default = { default?: { value: v } } +export type DefaultFor = + [t] extends [Primitive] ? (0 extends 1 & t ? Primitive : t) | (() => t) + : | (Primitive extends t ? Primitive + : t extends Primitive ? t + : never) + | (() => t) + export type InferredDefault = constrain> export type termOrType = t | Type diff --git a/ark/type/methods/base.ts b/ark/type/methods/base.ts index b102ab118..b20871d32 100644 --- a/ark/type/methods/base.ts +++ b/ark/type/methods/base.ts @@ -20,6 +20,7 @@ import type { ArkAmbient } from "../config.ts" import type { applyConstraint, Default, + DefaultFor, distill, inferIntersection, inferMorphOut, @@ -143,8 +144,8 @@ interface Type const value extends this["inferIn"], r = applyConstraint> >( - value: value - ): instantiateType + value: DefaultFor + ): NoInfer> // deprecate Function methods so they are deprioritized as suggestions diff --git a/ark/type/parser/ast/default.ts b/ark/type/parser/ast/default.ts index fbe05d201..a88edba5c 100644 --- a/ark/type/parser/ast/default.ts +++ b/ark/type/parser/ast/default.ts @@ -2,7 +2,7 @@ import type { writeUnassignableDefaultValueMessage } from "@ark/schema" import type { ErrorMessage } from "@ark/util" import type { type } from "../../keywords/keywords.ts" import type { UnitLiteral } from "../shift/operator/default.ts" -import type { inferAstOut } from "./infer.ts" +import type { inferAstIn } from "./infer.ts" import type { astToString } from "./utils.ts" import type { validateAst } from "./validate.ts" @@ -10,7 +10,7 @@ export type validateDefault = validateAst extends infer e extends ErrorMessage ? e : // check against the output of the type since morphs will not occur // ambient infer is safe since the default value is always a literal - type.infer extends inferAstOut ? undefined + type.infer extends inferAstIn ? undefined : ErrorMessage< writeUnassignableDefaultValueMessage, unitLiteral> > diff --git a/ark/type/parser/shift/operator/default.ts b/ark/type/parser/shift/operator/default.ts index 76d67b880..1d0c63953 100644 --- a/ark/type/parser/shift/operator/default.ts +++ b/ark/type/parser/shift/operator/default.ts @@ -27,8 +27,11 @@ export const parseDefault = (s: DynamicStateWithRoot): BaseRoot => { // token from which it was parsed if (!defaultNode.hasKind("unit")) return s.error(writeNonLiteralDefaultMessage(defaultNode.expression)) - - return baseNode.default(defaultNode.unit) + const defaultValue = + defaultNode.unit instanceof Date ? + () => new Date(defaultNode.unit as Date) + : defaultNode.unit + return baseNode.default(defaultValue) } export type parseDefault = diff --git a/ark/type/parser/tuple.ts b/ark/type/parser/tuple.ts index d2732bbe9..c64b4decd 100644 --- a/ark/type/parser/tuple.ts +++ b/ark/type/parser/tuple.ts @@ -29,6 +29,7 @@ import { import type { applyConstraint, Default, + DefaultFor, distill, inferIntersection, inferMorphOut, @@ -414,7 +415,7 @@ export type validateInfixExpression = : def[1] extends ":" ? Predicate> : def[1] extends "=>" ? Morph> : def[1] extends "@" ? MetaSchema - : def[1] extends "=" ? type.infer.Out + : def[1] extends "=" ? DefaultFor> : validateDefinition ] diff --git a/ark/type/type.ts b/ark/type/type.ts index 42bf45dfb..a4838dae9 100644 --- a/ark/type/type.ts +++ b/ark/type/type.ts @@ -23,7 +23,7 @@ import type { parseValidGenericParams, validateParameterString } from "./generic.ts" -import type { distill } from "./keywords/inference.ts" +import type { DefaultFor, distill } from "./keywords/inference.ts" import type { Ark, keywords, type } from "./keywords/keywords.ts" import type { BaseType } from "./methods/base.ts" import type { instantiateType } from "./methods/instantiate.ts" @@ -81,7 +81,7 @@ export interface TypeParser<$ = {}> extends Ark.boundTypeAttachments<$> { one extends ":" ? [Predicate>>] : one extends "=>" ? [Morph>, unknown>] : one extends "@" ? [MetaSchema] - : one extends "=" ? [distill.In, $>>] + : one extends "=" ? [DefaultFor, $>>>] : [type.validate] : [] ): r