Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions ark/type/__tests__/arrays/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ contextualize(() => {
)
})

it("empty array default in tuple literal", () => {
const T = type(["string[] = []"])
attest(T.t).type.toString.snap("[Default<string[], never[]>]")
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(
Expand Down
61 changes: 61 additions & 0 deletions ark/type/__tests__/objects/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[], never[]> }")
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<string, unknown>[]
}
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", () => {
Expand Down
22 changes: 14 additions & 8 deletions ark/type/parser/ast/default.ts
Original file line number Diff line number Diff line change
@@ -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<baseAst, unitLiteral extends UnitLiteral, $, args> =
export type validateDefault<baseAst, defaultLiteral extends string, $, args> =
validateAst<baseAst, $, args> 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<unitLiteral> extends inferAstIn<baseAst, $, args> ? undefined
: ErrorMessage<
writeUnassignableDefaultValueMessage<astToString<baseAst>, 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<baseAst, $, args> ?
undefined
: ErrorMessage<
writeUnassignableDefaultValueMessage<astToString<baseAst>, "[]">
>
: inferDefaultLiteral<defaultLiteral> extends inferAstIn<baseAst, $, args> ?
undefined
: ErrorMessage<
writeUnassignableDefaultValueMessage<astToString<baseAst>, defaultLiteral>
>
4 changes: 2 additions & 2 deletions ark/type/parser/ast/infer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,9 +55,8 @@ export type inferExpression<ast, $, args> =
inferExpression<ast[2], $, args>
>
: 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<ast[2]> extends infer defaultValue ?
inferDefaultLiteral<ast[2]> extends infer defaultValue ?
withDefault<inferExpression<ast[0], $, args>, defaultValue>
: never
: ast[1] extends "#" ? type.brand<inferExpression<ast[0], $, args>, ast[2]>
Expand Down
17 changes: 16 additions & 1 deletion ark/type/parser/shift/operator/default.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,13 +26,26 @@ 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> =
literal extends "[]" ? never[] : type.infer<literal>

export type ParsedDefaultableProperty = readonly [BaseRoot, "=", unknown]

export const parseDefault = (
s: RootedRuntimeState
): 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
Expand All @@ -49,7 +63,8 @@ export type parseDefault<root, unscanned extends string> =
// 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<unscanned> extends infer defaultExpression extends string ?
defaultExpression extends UnenclosedUnitLiteral ?
defaultExpression extends "[]" ? [root, "=", "[]"]
: defaultExpression extends UnenclosedUnitLiteral ?
[root, "=", defaultExpression]
: defaultExpression extends (
`${infer start extends EnclosingLiteralStartToken}${string}`
Expand Down
Loading