From e0d1b82457e5108126a5127a1357910feee96d73 Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Wed, 18 Feb 2026 17:37:53 -0500 Subject: [PATCH 1/2] test: add partial/required tuple conversion tests --- ark/type/__tests__/keywords/partial.test.ts | 35 ++++++++++++++++++++ ark/type/__tests__/keywords/required.test.ts | 33 ++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/ark/type/__tests__/keywords/partial.test.ts b/ark/type/__tests__/keywords/partial.test.ts index 84f00e0dde..26fdfcdb98 100644 --- a/ark/type/__tests__/keywords/partial.test.ts +++ b/ark/type/__tests__/keywords/partial.test.ts @@ -35,4 +35,39 @@ contextualize(() => { attest(T.expression).snap("{ [string]: number, bar?: 1, foo?: 1 }") }) + + // https://github.com/arktypeio/arktype/issues/1480 + it("tuple literal", () => { + const T = type(["'foo'", "'bar'"]).partial() + const Expected = type(["'foo'?", "'bar'?"]) + + attest(T.t) + attest<["foo"?, "bar"?]>(T.infer) + attest(T([])).equals([]) + attest(T(["foo"])).equals(["foo"]) + attest(T(["foo", "bar"])).equals(["foo", "bar"]) + }) + + it("string syntax tuple", () => { + const tupleScope = scope({ + tuple: ["'foo'", "'bar'"] + }) + + const T = tupleScope.type("Partial") + const Expected = type(["'foo'?", "'bar'?"]) + + attest(T.t) + attest<["foo"?, "bar"?]>(T.infer) + attest(T.expression).equals(Expected.expression) + }) + + it("tuple with defaultable", () => { + const T = type(["string", "number = 5"]).partial() + const Expected = type(["string?", "number?"]) + + // https://github.com/arktypeio/arktype/issues/1160 + // attest(T.t) + attest(T.expression).equals(Expected.expression) + attest(T([])).equals([]) + }) }) diff --git a/ark/type/__tests__/keywords/required.test.ts b/ark/type/__tests__/keywords/required.test.ts index ac2dd55764..8dc8d422bf 100644 --- a/ark/type/__tests__/keywords/required.test.ts +++ b/ark/type/__tests__/keywords/required.test.ts @@ -48,4 +48,37 @@ contextualize(() => { attest(T.expression).equals(Expected.expression) }) + + // reverse of https://github.com/arktypeio/arktype/issues/1480 + it("tuple literal", () => { + const T = type(["'foo'?", "'bar'?"]).required() + const Expected = type(["'foo'", "'bar'"]) + + attest(T.t) + attest<["foo", "bar"]>(T.infer) + attest(T.expression).equals(Expected.expression) + attest(T(["foo", "bar"])).equals(["foo", "bar"]) + }) + + it("string syntax tuple", () => { + const tupleScope = scope({ + tuple: ["'foo'?", "'bar'?"] + }) + const T = tupleScope.type("Required") + const Expected = type(["'foo'", "'bar'"]) + + attest(T.t) + attest<["foo", "bar"]>(T.infer) + attest(T.expression).equals(Expected.expression) + attest(T(["foo", "bar"])).equals(["foo", "bar"]) + }) + + it("tuple with defaultable", () => { + const T = type(["string", "number = 5"]).required() + const Expected = type(["string", "number"]) + + // https://github.com/arktypeio/arktype/issues/1160 + // attest(T.t) + attest(T.expression).equals(Expected.expression) + }) }) From 7e10e652466b485003d5112da0443c4b7e1be72b Mon Sep 17 00:00:00 2001 From: Drew Powers Date: Wed, 18 Feb 2026 17:37:56 -0500 Subject: [PATCH 2/2] feat: handle sequence optionalize/required for tuple transitions --- ark/schema/structure/sequence.ts | 31 +++++++++++++++++++++++++++++++ ark/schema/structure/structure.ts | 6 ++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ark/schema/structure/sequence.ts b/ark/schema/structure/sequence.ts index 527bdbc95b..ef03e99416 100644 --- a/ark/schema/structure/sequence.ts +++ b/ark/schema/structure/sequence.ts @@ -1,6 +1,7 @@ import { append, conflatenate, + conflatenateAll, printable, throwInternalError, throwParseError, @@ -481,6 +482,36 @@ export class SequenceNode extends BaseConstraint { return result } + optionalize(): SequenceNode { + if (this.postfix) return this + if (!this.prefix?.length && !this.defaultables?.length) return this + + const { prefix, defaultables, ...inner } = this.inner + return this.$.node("sequence", { + ...inner, + optionals: conflatenateAll( + prefix, + defaultables?.map(d => d[0]), + inner.optionals + ) + }) + } + + require(): SequenceNode { + if (this.postfix) return this + if (!this.optionals?.length && !this.defaultables?.length) return this + + const { optionals, defaultables, ...inner } = this.inner + return this.$.node("sequence", { + ...inner, + prefix: conflatenateAll( + inner.prefix, + defaultables?.map(d => d[0]), + optionals + ) + }) + } + // this depends on tuple so needs to come after it expression: string = this.description diff --git a/ark/schema/structure/structure.ts b/ark/schema/structure/structure.ts index ca6b6951bf..f2cfabc187 100644 --- a/ark/schema/structure/structure.ts +++ b/ark/schema/structure/structure.ts @@ -564,9 +564,10 @@ export class StructureNode extends BaseConstraint { } optionalize(): StructureNode { - const { required, ...inner } = this.inner + const { required: _, sequence, ...inner } = this.inner return this.$.node("structure", { ...inner, + ...(sequence ? { sequence: sequence.optionalize() } : {}), optional: this.props.map(prop => prop.hasKind("required") ? this.$.node("optional", prop.inner) : prop ) @@ -574,9 +575,10 @@ export class StructureNode extends BaseConstraint { } require(): StructureNode { - const { optional, ...inner } = this.inner + const { optional: _, sequence, ...inner } = this.inner return this.$.node("structure", { ...inner, + ...(sequence ? { sequence: sequence.require() } : {}), required: this.props.map(prop => prop.hasKind("optional") ? {