Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
59 changes: 59 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,65 @@ 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 = []" })).type.errors(
Comment thread
SynthLuvr marked this conversation as resolved.
Outdated
"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