diff --git a/ark/type/__tests__/arrays/defaults.test.ts b/ark/type/__tests__/arrays/defaults.test.ts index ada163d98e..c8a6f83841 100644 --- a/ark/type/__tests__/arrays/defaults.test.ts +++ b/ark/type/__tests__/arrays/defaults.test.ts @@ -87,6 +87,13 @@ contextualize(() => { ) }) + it("empty array default in tuple literal", () => { + const T = type(["string[] = []"]) + attest(T.t).type.toString.snap("[Default]") + attest(T([])).equals([[]]) // outer is the tuple, inner is the defaulted array + attest(T([["a"]])).equals([["a"]]) + }) + it("default after undefaulted optional", () => { // @ts-expect-error attest(() => type(["number?", "number = 5"])).throwsAndHasTypeError( diff --git a/ark/type/__tests__/objects/defaults.test.ts b/ark/type/__tests__/objects/defaults.test.ts index f0bdfa7483..b30788fa7a 100644 --- a/ark/type/__tests__/objects/defaults.test.ts +++ b/ark/type/__tests__/objects/defaults.test.ts @@ -535,6 +535,67 @@ contextualize(() => { .throws(writeUnexpectedCharacterMessage("|")) .type.errors(writeNonLiteralDefaultMessage("'n' |> 'y'")) }) + + it("empty array default via string syntax", () => { + const O = type({ values: "string[] = []" }) + + attest(O.t).type.toString.snap("{ values: Default }") + attest<{ values?: string[] }>(O.inferIn) + attest<{ values: string[] }>(O.infer) + + attest(O({})).equals({ values: [] }) + attest(O({ values: ["1", "2", "3"] })).equals({ + values: ["1", "2", "3"] + }) + }) + + it("empty array default returns a fresh array each call", () => { + const O = type({ values: "string[] = []" }) + const a = O.assert({}) + const b = O.assert({}) + attest(a.values !== b.values) // different references + attest(a.values).equals([]) + }) + + it("empty array default equivalent to tuple thunk", () => { + const viaString = type({ values: "string[] = []" }) + const viaTuple = type({ values: ["string[]", "=", () => []] }) + // the default is a distinct thunk per instance, so strip it before + // comparing the two schemas + const stripDefault = (json: typeof viaString.json) => { + const clone = JSON.parse(JSON.stringify(json)) as { + optional?: Record[] + } + for (const prop of clone.optional ?? []) delete prop.default + return clone + } + attest(stripDefault(viaString.json)).equals(stripDefault(viaTuple.json)) + attest(viaString.assert({}).values).equals([]) + attest(viaTuple.assert({}).values).equals([]) + }) + + it("empty array default rejects trailing junk", () => { + // runtime rejects the leftover `x`; type-level parseDefault reports + // `[]x` as a non-literal default (mirrors "'n' = 'n' |> 'y'") + attest(() => + type({ + // @ts-expect-error + values: "string[] = []x" + }) + ) + .throws(writeUnexpectedCharacterMessage("x")) + .type.errors(writeNonLiteralDefaultMessage("[]x")) + }) + + it("empty array default rejects non-array base", () => { + // [] is assignable only to array/tuple input types + // @ts-expect-error + attest(() => type({ foo: "number = []" })) + .throws.snap( + "ParseError: Default for foo must be a number (was an object)" + ) + .type.errors("Default value [] must be assignable to number") + }) }) describe("works properly with types", () => { diff --git a/ark/type/parser/ast/default.ts b/ark/type/parser/ast/default.ts index a88edba5cc..bd2ca833fd 100644 --- a/ark/type/parser/ast/default.ts +++ b/ark/type/parser/ast/default.ts @@ -1,16 +1,22 @@ 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 { inferDefaultLiteral } from "../shift/operator/default.ts" import type { inferAstIn } from "./infer.ts" import type { astToString } from "./utils.ts" import type { validateAst } from "./validate.ts" -export type validateDefault = +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 inferAstIn ? undefined - : ErrorMessage< - writeUnassignableDefaultValueMessage, unitLiteral> + : [defaultLiteral] extends [never] ? + // "[]" is narrowed to never by r & UnitLiteral in validate.ts; [] is + // assignable only to array/tuple input types + never[] extends inferAstIn ? + undefined + : ErrorMessage< + writeUnassignableDefaultValueMessage, "[]"> + > + : inferDefaultLiteral extends inferAstIn ? + undefined + : ErrorMessage< + writeUnassignableDefaultValueMessage, defaultLiteral> > diff --git a/ark/type/parser/ast/infer.ts b/ark/type/parser/ast/infer.ts index 54c2cc0b26..737377a41e 100644 --- a/ark/type/parser/ast/infer.ts +++ b/ark/type/parser/ast/infer.ts @@ -10,6 +10,7 @@ import type { type } from "../../keywords/keywords.ts" import type { inferDefinition } from "../definition.ts" import type { Comparator } from "../reduce/shared.ts" import type { InfixToken, PostfixToken } from "../shift/tokens.ts" +import type { inferDefaultLiteral } from "../shift/operator/default.ts" import type { GenericInstantiationAst, inferGenericInstantiation @@ -54,9 +55,8 @@ export type inferExpression = inferExpression > : ast[1] extends "=" ? - // unscoped type.infer is safe since the default value is always a literal // as of TS5.6, inlining defaultValue causes a bunch of extra types and instantiations - type.infer extends infer defaultValue ? + inferDefaultLiteral extends infer defaultValue ? withDefault, defaultValue> : never : ast[1] extends "#" ? type.brand, ast[2]> diff --git a/ark/type/parser/shift/operator/default.ts b/ark/type/parser/shift/operator/default.ts index e68954aa28..d64be956bc 100644 --- a/ark/type/parser/shift/operator/default.ts +++ b/ark/type/parser/shift/operator/default.ts @@ -6,6 +6,7 @@ import type { Scanner, trim } from "@ark/util" +import type { type } from "../../../keywords/keywords.ts" import type { DateLiteral } from "../../../attributes.ts" import type { RootedRuntimeState } from "../../reduce/dynamic.ts" import type { @@ -25,6 +26,12 @@ export type UnenclosedUnitLiteral = export type EnclosedUnitLiteral = StringLiteral | DateLiteral +// "[]" can't resolve via type.infer (it would be the empty-group type `never`, +// not never[]), so it's special-cased. Unit literals are self-contained, so +// unscoped type.infer is safe for them. +export type inferDefaultLiteral = + literal extends "[]" ? never[] : type.infer + export type ParsedDefaultableProperty = readonly [BaseRoot, "=", unknown] export const parseDefault = ( @@ -32,6 +39,13 @@ export const parseDefault = ( ): ParsedDefaultableProperty => { // store the node that will be bounded const baseNode = s.unsetRoot() + // "[]" is the only non-unit default currently supported and must be + // represented as a thunk so each traversal gets a fresh array + s.scanner.shiftUntilNonWhitespace() + if (s.scanner.unscanned.startsWith("[]")) { + s.scanner.jumpForward(2) + return [baseNode, "=", () => []] + } s.parseOperand() const defaultNode = s.unsetRoot() // after parsing the next operand, use the locations to get the @@ -49,7 +63,8 @@ export type parseDefault = // default values must always appear at the end of a string definition, // so parse the rest of the string and ensure it is a valid unit literal trim extends infer defaultExpression extends string ? - defaultExpression extends UnenclosedUnitLiteral ? + defaultExpression extends "[]" ? [root, "=", "[]"] + : defaultExpression extends UnenclosedUnitLiteral ? [root, "=", defaultExpression] : defaultExpression extends ( `${infer start extends EnclosingLiteralStartToken}${string}`