diff --git a/.gitignore b/.gitignore index 116d71429..d3226aeca 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,11 @@ tmp *.log *.tsbuildinfo .DS_Store -.docusaurus -.astro .next .source .cache-loader .attest +tsconfig.build.json coverage # we avoid committing the root pnpm-lock in order to keep the root of the repo as clean as possible. # we can get away with this to since we're only installing devDependencies and they're all pinned. diff --git a/ark/attest/package.json b/ark/attest/package.json index 958716405..759363c1c 100644 --- a/ark/attest/package.json +++ b/ark/attest/package.json @@ -1,6 +1,6 @@ { "name": "@ark/attest", - "version": "0.44.1", + "version": "0.44.2", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/fs/package.json b/ark/fs/package.json index 156cbe3d6..2574a20a1 100644 --- a/ark/fs/package.json +++ b/ark/fs/package.json @@ -1,6 +1,6 @@ { "name": "@ark/fs", - "version": "0.44.1", + "version": "0.44.2", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/repo/scratch.ts b/ark/repo/scratch.ts index 0795289e9..83e5165d2 100644 --- a/ark/repo/scratch.ts +++ b/ark/repo/scratch.ts @@ -1,7 +1 @@ import { type } from "arktype" - -export const urDOOMed = type({ - grouping: "(0 | (1 | (2 | (3 | (4 | 5)[])[])[])[])[]", - nestedGenerics: "Exclude<0n | unknown[] | Record, object>", - "escapes\\?": "'a | b' | 'c | d'" -}) diff --git a/ark/repo/scratch/fn.ts b/ark/repo/scratch/fn.ts index ff5572b04..f58bb1f30 100644 --- a/ark/repo/scratch/fn.ts +++ b/ark/repo/scratch/fn.ts @@ -10,7 +10,7 @@ export type FunctionParser<$> = { // implementation: implementation // ) => implementation - ( + ( arg0: type.validate, _?: ":", ret?: type.validate @@ -22,7 +22,7 @@ export type FunctionParser<$> = { implementation: implementation ) => implementation - ( + ( arg0: type.validate, arg1: type.validate, _?: ":", @@ -36,7 +36,7 @@ export type FunctionParser<$> = { implementation: implementation ) => implementation - ( + ( arg0: type.validate, arg1: type.validate, arg2: type.validate, diff --git a/ark/schema/package.json b/ark/schema/package.json index 56eefcaa9..35b6f29fd 100644 --- a/ark/schema/package.json +++ b/ark/schema/package.json @@ -1,6 +1,6 @@ { "name": "@ark/schema", - "version": "0.44.1", + "version": "0.44.2", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/schema/shared/errors.ts b/ark/schema/shared/errors.ts index 61b0bf02b..abb9b8cbf 100644 --- a/ark/schema/shared/errors.ts +++ b/ark/schema/shared/errors.ts @@ -228,13 +228,6 @@ export class ArkErrors return this.toString() } - /** - * Alias of `summary` for StandardSchema compatibility. - */ - get message(): string { - return this.toString() - } - /** * Alias of this ArkErrors instance for StandardSchema compatibility. */ diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index 3b78a7785..9c46a1057 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -602,7 +602,9 @@ export class StructureNode extends BaseConstraint { } } - if (this.structuralMorph && !ctx.hasError()) + // added additional ctx check here to address + // https://github.com/arktypeio/arktype/issues/1346 + if (this.structuralMorph && ctx && !ctx.hasError()) ctx.queueMorphs([this.structuralMorph]) return true @@ -653,7 +655,9 @@ export class StructureNode extends BaseConstraint { // always queue deleteUndeclared on valid traversal for "delete" if (this.structuralMorphRef) { - js.if("!ctx.hasError()", () => + // added additional ctx check here to address + // https://github.com/arktypeio/arktype/issues/1346 + js.if("ctx && !ctx.hasError()", () => js.line(`ctx.queueMorphs([${this.structuralMorphRef}])`) ) } diff --git a/ark/type/CHANGELOG.md b/ark/type/CHANGELOG.md index 84089e044..881b60644 100644 --- a/ark/type/CHANGELOG.md +++ b/ark/type/CHANGELOG.md @@ -1,5 +1,15 @@ # arktype +## 2.1.7 + +Address a rare crash on an invalid ctx reference in some jitless cases + +Closes #1346 + +## 2.1.6 + +Improve some type-level parse errors on expressions with invalid finalizers + ## 2.1.5 #### Fix JSDoc and go-to definition for unparsed keys diff --git a/ark/type/__tests__/match.test.ts b/ark/type/__tests__/match.test.ts index abacff005..231aee98c 100644 --- a/ark/type/__tests__/match.test.ts +++ b/ark/type/__tests__/match.test.ts @@ -1,5 +1,9 @@ import { attest, contextualize } from "@ark/attest" -import { registeredReference, writeUnboundableMessage } from "@ark/schema" +import { + registeredReference, + writeUnboundableMessage, + type ArkErrors +} from "@ark/schema" import { match, scope, type } from "arktype" import type { Out } from "arktype/internal/attributes.ts" import { doubleAtMessage, throwOnDefault } from "arktype/internal/match.ts" @@ -211,7 +215,7 @@ contextualize(() => { // @ts-expect-error attest(() => matcher(true)) - .throws.snap("AggregateError: must be a string or a number (was boolean)") + .throws.snap("AggregateError: must be a number or a string (was boolean)") .type.errors( "Argument of type 'boolean' is not assignable to parameter of type 'string | number'" ) @@ -704,15 +708,6 @@ contextualize(() => { default: "reject" }) - attest(m).type.toString.snap(`Match< - unknown, - [ - (In: number) => number, - (In: string) => number, - (In: unknown) => ArkErrors - ] ->`) - attest(m("foo")).equals(3) attest(m(3)).equals(3) // can access directly since it has no overlap with input @@ -735,4 +730,116 @@ contextualize(() => { "AggregateError: must be a string, a number, a bigint or an object (was null)" ) }) + + it("validates in", () => { + const exclaimFoo = match.in({ foo: "string" }).at("foo", { + default: o => `${o.foo}!` as const + }) + + attest(exclaimFoo).type.toString.snap(`Match< + { foo: string }, + [ + (In: unknown) => ArkErrors, + (o: { foo: string }) => \`\${string}!\` + ] +>`) + + const out = exclaimFoo({ foo: "foo" }) + + // ensure ArkErrors is added as a possible outcome + // since input is validated without assertion + attest(out).equals("foo!") + + // @ts-expect-error + attest(exclaimFoo({ foo: 5 }).toString()) + .snap("foo must be a string (was a number)") + .type.errors("Type 'number' is not assignable to type 'string'") + }) + + it("asserts in", () => { + const fooToLength = match.in({ foo: "string" }).at("foo", { + "string > 0": o => o.foo.length, + default: "assert" + }) + + attest(fooToLength).type.toString.snap(`Match< + { foo: string }, + [(In: { foo: string }) => number] +>`) + + const out = fooToLength({ foo: "foo" }) + + // ensure ArkErrors is not added to output + // since result is asserted + attest(out).equals(3) + + // @ts-expect-error + attest(() => fooToLength({ foo: 5 })) + .throws("foo must be a string (was a number)") + .type.errors("Type 'number' is not assignable to type 'string'") + }) + + type Discriminated = + | { + kind: "a" + value: "a" + } + | { + kind: "b" + value: "b" + } + | { + kind: "c" + value: "c" + } + + it("string literal matcher", () => { + const discriminate = match + .in() + .at("kind") + .strings({ + a: o => o.value, + b: o => o.value, + c: o => o.value, + default: "assert" + }) + + const a = discriminate({ kind: "a", value: "a" }) + const b = discriminate({ kind: "b", value: "b" }) + const c = discriminate({ kind: "c", value: "c" }) + + attest<["a", "b", "c"]>([a, b, c]).snap(["a", "b", "c"]) + + // @ts-expect-error + attest(() => discriminate({ kind: "d", value: "d" })) + .throws.snap('AggregateError: kind must be "a", "b" or "c" (was "d")') + .type.errors(`Type '"d"' is not assignable`) + }) + + it("invalid string key", () => { + attest(() => + match + .in() + .at("kind") + .strings({ + // @ts-expect-error + d: o => o.value, + default: "assert" + }) + ).type.errors(`ErrorType<"d must be a possible string value", {}>`) + }) + + it("lone invalid string key", () => { + attest(() => + match + .in() + .at("kind") + .strings({ + // @ts-expect-error + d: o => o.value + }) + ).type.errors( + `Object literal may only specify known properties, and 'd' does not exist` + ) + }) }) diff --git a/ark/type/__tests__/realWorld.test.ts b/ark/type/__tests__/realWorld.test.ts index 0f6a4461e..e880d212f 100644 --- a/ark/type/__tests__/realWorld.test.ts +++ b/ark/type/__tests__/realWorld.test.ts @@ -1259,4 +1259,39 @@ Right: { x: number, y: number, + (undeclared): delete }`) attest(tupleArrayType.assert([[1, 2]])).equals([[1, 2]]) attest(unionType.assert([[1, 2]])).equals([[1, 2]]) }) + + it("doomed shirt example", () => { + const urDOOMed = type({ + grouping: "(0 | (1 | (2 | (3 | (4 | 5)[])[])[])[])[]", + nestedGenerics: + "Exclude<0n | unknown[] | Record, object>", + "escapes\\?": "'a | b' | 'c | d'" + }) + + attest<{ + grouping: (0 | (1 | (2 | (3 | (4 | 5)[])[])[])[])[] + nestedGenerics: 0n + "escapes?": "a | b" | "c | d" + }>(urDOOMed.t) + + attest(urDOOMed.expression).snap( + '{ escapes?: "a | b" | "c | d", grouping: (((((4 | 5)[] | 3)[] | 2)[] | 1)[] | 0)[], nestedGenerics: 0n }' + ) + }) + + it("ArkErrors not assignable to ArkErrorInput", () => { + attest(() => + type({ + type: "string" + }).narrow((_, ctx) => { + const result = type.number("foo") + // @ts-expect-error + if (result instanceof type.errors) return ctx.reject(result) + + return true + }) + ).type.errors( + "Argument of type 'ArkErrors' is not assignable to parameter of type 'ArkErrorInput'" + ) + }) }) diff --git a/ark/type/match.ts b/ark/type/match.ts index 565672bcc..026877e15 100644 --- a/ark/type/match.ts +++ b/ark/type/match.ts @@ -29,16 +29,18 @@ type MatchParserContext = { cases: Morph[] $: unknown input: input + checked: boolean key: PropertyKey | null } declare namespace ctx { export type from = ctx - export type init<$, input = unknown> = from<{ + export type init<$, input = unknown, checked extends boolean = false> = from<{ cases: [] $: $ input: input + checked: checked key: null }> @@ -46,6 +48,7 @@ declare namespace ctx { cases: ctx["cases"] $: ctx["$"] input: ctx["input"] + checked: ctx["checked"] key: key }> } @@ -53,18 +56,18 @@ declare namespace ctx { export interface MatchParser<$> extends CaseMatchParser> { in( def: type.validate - ): ChainableMatchParser>> + ): ChainableMatchParser, true>> in( ...args: [typedInput] extends [never] ? [ - ErrorMessage<"from requires a definition or type argument (from('string') or from())"> + ErrorMessage<"in requires a definition or type argument (in('string') or in())"> ] : [] ): ChainableMatchParser> - // include this signature a second time so that e.g. `match.from({ foo: "strin" })` shows the right error + // include this signature a second time so that e.g. `match.in({ foo: "strin" })` shows the right error in( def: type.validate - ): ChainableMatchParser>> + ): ChainableMatchParser, true>> case: CaseParser> @@ -80,6 +83,7 @@ type addCasesToContext< $: ctx["$"] input: ctx["input"] cases: [...ctx["cases"], ...cases] + checked: ctx["checked"] key: ctx["key"] }> : never @@ -91,42 +95,65 @@ type addDefaultToContext< $: ctx["$"] input: defaultCase extends "never" ? Morph.In : ctx["input"] - cases: defaultCase extends Morph ? [...ctx["cases"], defaultCase] - : defaultCase extends "never" | "assert" ? ctx["cases"] - : [...ctx["cases"], (In: ctx["input"]) => ArkErrors] + cases: defaultCase extends "never" | "assert" ? ctx["cases"] + : defaultCase extends Morph ? + ctx["checked"] extends true ? + [(In: unknown) => ArkErrors, ...ctx["cases"], defaultCase] + : [...ctx["cases"], defaultCase] + : // we already are guaranteed ArkErrors as a possible output here + // so don't bother adding it as an input case + [...ctx["cases"], (In: ctx["input"]) => ArkErrors] + checked: ctx["checked"] key: ctx["key"] }> -type casesToMorphTuple = unionToTuple< +type CaseKeyKind = "def" | "string" + +type casesToMorphTuple< + cases, + ctx extends MatchParserContext, + kind extends CaseKeyKind +> = unionToTuple< propValueOf<{ [def in Exclude]: cases[def] extends ( Morph ) ? - (In: inferCaseArg) => o + kind extends "def" ? + ( + In: inferCaseArg + ) => o + : (In: maybeLiftToKey) => o : never }> > -type addCasesToParser = +type addCasesToParser< + cases, + ctx extends MatchParserContext, + kind extends CaseKeyKind +> = cases extends { default: infer defaultDef extends DefaultCase } ? finalizeMatchParser< - addCasesToContext>, + addCasesToContext>, defaultDef > - : ChainableMatchParser>> + : ChainableMatchParser< + addCasesToContext> + > type inferCaseArg< def, ctx extends MatchParserContext, endpoint extends "in" | "out" > = _finalizeCaseArg< - ctx["key"] extends PropertyKey ? - { [k in ctx["key"]]: type.infer } - : type.infer, + maybeLiftToKey, ctx>, ctx, endpoint > +type maybeLiftToKey = + ctx["key"] extends PropertyKey ? { [k in ctx["key"]]: t } : t + type _finalizeCaseArg< t, ctx extends MatchParserContext, @@ -153,6 +180,29 @@ type validateKey = : conform : ErrorMessage +interface StringsParser { + ( + def: cases extends validateStringCases ? cases + : validateStringCases + ): addCasesToParser +} + +type validateStringCases = { + [k in keyof cases | stringValue | "default"]?: k extends "default" ? + DefaultCase + : k extends stringValue ? + (In: _finalizeCaseArg, ctx, "out">) => unknown + : ErrorType<`${k & string} must be a possible string value`> +} + +type stringValue = + ctx["key"] extends keyof ctx["input"] ? + ctx["input"][ctx["key"]] extends string ? + ctx["input"][ctx["key"]] + : never + : ctx["input"] extends string ? ctx["input"] + : never + interface AtParser { ( key: validateKey @@ -166,7 +216,7 @@ interface AtParser { key: validateKey, cases: cases extends validateCases ? cases : errorCases - ): addCasesToParser + ): addCasesToParser } interface ChainableMatchParser { @@ -174,6 +224,8 @@ interface ChainableMatchParser { match: CaseMatchParser default: DefaultMethod at: AtParser + /** @experimental */ + strings: StringsParser } export type DefaultCaseKeyword = "never" | "assert" | "reject" @@ -215,7 +267,7 @@ type errorCases = { export type CaseMatchParser = ( def: cases extends validateCases ? cases : errorCases -) => addCasesToParser +) => addCasesToParser type finalizeMatchParser< ctx extends MatchParserContext, @@ -253,8 +305,11 @@ export class InternalMatchParser extends Callable { this.$ = $ } - in(): InternalChainedMatchParser { - return new InternalChainedMatchParser(this.$) + in(def?: unknown): InternalChainedMatchParser { + return new InternalChainedMatchParser( + this.$, + def === undefined ? undefined : this.$.parse(def) + ) } at(key: Key, cases?: InternalCases): InternalChainedMatchParser | Match { @@ -272,36 +327,24 @@ type InternalCaseParserFn = ( cases: InternalCases ) => InternalChainedMatchParser | Match +type CaseEntry = [BaseRoot, Morph] | ["default", DefaultCase] + export class InternalChainedMatchParser extends Callable { - $: InternalScope + $: InternalScope; + in: BaseRoot | undefined protected key: Key | undefined protected branches: BaseRoot[] = [] - constructor($: InternalScope) { - super(cases => { - const entries = Object.entries(cases) - for (let i = 0; i < entries.length; i++) { - const [k, v] = entries[i] - if (k === "default") { - if (i !== entries.length - 1) { - throwParseError( - `default may only be specified as the last key of a switch definition` - ) - } - return this.default(v) - } - if (typeof v !== "function") { - return throwParseError( - `Value for case "${k}" must be a function (was ${domainOf(v)})` - ) - } - - this.case(k, v) - } - - return this - }) + constructor($: InternalScope, In?: BaseRoot) { + super(cases => + this.caseEntries( + Object.entries(cases).map(([k, v]) => + k === "default" ? [k, v] : [this.$.parse(k), v as Morph] + ) + ) + ) this.$ = $ + this.in = In } at(key: Key, cases?: InternalCases): InternalChainedMatchParser | Match { @@ -312,8 +355,15 @@ export class InternalChainedMatchParser extends Callable { } case(def: unknown, resolver: Morph): InternalChainedMatchParser { - const wrappableDef = this.key ? { [this.key]: def } : def - const branch = this.$.parse(wrappableDef).pipe(resolver as never) + return this.caseEntry(this.$.parse(def), resolver) + } + + protected caseEntry( + node: BaseRoot, + resolver: Morph + ): InternalChainedMatchParser { + const wrappableNode = this.key ? this.$.parse({ [this.key]: node }) : node + const branch = wrappableNode.pipe(resolver as never) this.branches.push(branch) return this } @@ -322,6 +372,41 @@ export class InternalChainedMatchParser extends Callable { return this(cases) } + strings(cases: InternalCases): InternalChainedMatchParser | Match { + return this.caseEntries( + Object.entries(cases).map(([k, v]) => + k === "default" ? + [k, v] + : [this.$.node("unit", { unit: k }), v as Morph] + ) + ) + } + + protected caseEntries( + entries: CaseEntry[] + ): InternalChainedMatchParser | Match { + for (let i = 0; i < entries.length; i++) { + const [k, v] = entries[i] + if (k === "default") { + if (i !== entries.length - 1) { + throwParseError( + `default may only be specified as the last key of a switch definition` + ) + } + return this.default(v) + } + if (typeof v !== "function") { + return throwParseError( + `Value for case "${k}" must be a function (was ${domainOf(v)})` + ) + } + + this.caseEntry(k, v) + } + + return this + } + default(defaultCase: DefaultCase): Match { if (typeof defaultCase === "function") this.case(intrinsic.unknown, defaultCase) @@ -334,9 +419,19 @@ export class InternalChainedMatchParser extends Callable { if (defaultCase === "never" || defaultCase === "assert") schema.meta = { onFail: throwOnDefault } - const matcher = this.$.finalize(this.$.node("union", schema)) + const cases = this.$.node("union", schema) + + if (!this.in) return this.$.finalize(cases) as never + + let inputValidatedCases = this.in.pipe(cases) + + if (defaultCase === "never" || defaultCase === "assert") { + inputValidatedCases = inputValidatedCases.withMeta({ + onFail: throwOnDefault + }) + } - return matcher as never + return this.$.finalize(inputValidatedCases) as never } } diff --git a/ark/type/package.json b/ark/type/package.json index a96147e6d..00200ef0b 100644 --- a/ark/type/package.json +++ b/ark/type/package.json @@ -1,7 +1,7 @@ { "name": "arktype", "description": "Optimized runtime validation for TypeScript syntax", - "version": "2.1.6", + "version": "2.1.7", "license": "MIT", "repository": { "type": "git", diff --git a/ark/util/package.json b/ark/util/package.json index 3b5c57343..219ad9200 100644 --- a/ark/util/package.json +++ b/ark/util/package.json @@ -1,6 +1,6 @@ { "name": "@ark/util", - "version": "0.44.1", + "version": "0.44.2", "license": "MIT", "author": { "name": "David Blass", diff --git a/ark/util/registry.ts b/ark/util/registry.ts index bdc534812..17bb8bc8e 100644 --- a/ark/util/registry.ts +++ b/ark/util/registry.ts @@ -8,7 +8,7 @@ import { FileConstructor, objectKindOf } from "./objectKinds.ts" // recent node versions (https://nodejs.org/api/esm.html#json-modules). // For now, we assert this matches the package.json version via a unit test. -export const arkUtilVersion = "0.44.1" +export const arkUtilVersion = "0.44.2" export const initialRegistryContents = { version: arkUtilVersion,