[Bug] Nested morphs not being applied correctly
Report a bug
🔎 Search Terms
nested morphs, default, pipe
🧩 Context
- ArkType version: 2.1.22
- TypeScript version (5.1+): 5.9.2
- Other context you think may be relevant (JS flavor, OS, etc.): System: OS: Linux 6.16 Arch Linux Binaries: Node: 22.18.0 - /nix/store/vrqcpwq576gar2i430lj91v37b7k8jw2-nodejs-22.18.0/bin/node npm: 10.9.3 - /nix/store/vrqcpwq576gar2i430lj91v37b7k8jw2-nodejs-22.18.0/bin/npm bun: 1.2.13 - /nix/store/qpcadmqli2421b6whq5ffd0nlxncj856-bun-1.2.13/bin/bun
🧑💻 Repro
Playground Link: N/A (Playground unable to compile this snippet)
import { type Out, Type, type } from 'arktype'
class Option<T> {
constructor(public value: T) {}
static fromUndefinedOrNull<T>(value: T): Option<T> {
return new Option(value)
}
}
const asOption = <const def, T = type.infer<def>>(
t: type.validate<def>,
): Type<(In: T | undefined | null) => Out<Option<T>>> => {
const T = type(t).or('undefined | null') as unknown as Type<
T | undefined | null
>
return T.pipe((value) => Option.fromUndefinedOrNull(value))
}
const asOptionWithDefault = <const def, T = type.infer<def>>(
t: type.validate<def>,
): [Type<(In: T | undefined | null) => Out<Option<T>>>, '=', null] => {
return asOption(t).default(null as any) as never
}
const x = type({
a: asOptionWithDefault('number'),
})
const y = type({
a: asOptionWithDefault(x),
})
console.log(y.assert({ a: {} }))
// prints:
// { a: Option { value: { a: null, } } }
// expected:
// { a: Option { value: { a: Option { ... } } } }
export { asOption, asOptionWithDefault }
This looks like a bug with the way morphs are either queued or applied during traversal.
If you (or anyone) is interested in taking a look at this, I'd start by using your repro as a test and working through the logic that applies morphs here:
https://github.com/arktypeio/arktype/blob/ff7db8b61004f8de82089a22f4cb4ab5938c2a97/ark/schema/shared/traversal.ts#L219
If the correct morphs seem to be queued at the end of traversal (including defaults and standard morphs), the bug must be in the application. Otherwise, you'd want to look at the queueMorphs calls made for default values and see if you can adjust those (both in the JIT-compiled strings and the dynamic version of the apply methods).
I'm partially writing this as notes for myself when I get back from some planned travel, but if you're able to delve in and find a solution that would be a huge W and I could likely merge it and publish a new version right away 🙏
are there docs on the testing utility? I am looking at the test code and can't figure out how to even create a type rootSchema doesn't seem to take a type definiton.
Ahh sorry don't worry about that.
It is the internal representation of a Type but you should just create the "end-to-end" version of the test using the standard arktype API in ark/type/__tests__/defaults.test.ts.
No rush on this but before I try to look into the code and fix this would appreciate if you could see if I wrote the test correctly
Test:
it("nested morphed", () => {
class Option<T> {
constructor(public value: T | undefined | null) {}
}
const T = type({
a: type("string | null | undefined")
.pipe(value => new Option(value))
.default(null)
})
const U = type({
a: T.or("undefined | null")
.pipe(value => new Option(value))
.default(null)
})
const out = U({})
attest(out).snap({ a: new Option(null) })
const out2 = U({ a: {} })
attest(out2).snap({ a: new Option({ a: new Option(null) }) })
})
Failed Output:
1 failing
1) defaults
parsing and traversal
nested morphed:
Expected values to be strictly deep-equal:
+ actual - expected
{
a: {
value: {
+ a: null
- a: {
- value: null
- }
}
}
}
+ expected - actual
{
"a": {
"value": {
- "a": [null]
+ "a": {
+ "value": [null]
+ }
}
}
}
Thanks
This looks like it works, but a couple suggestions:
-
Try to make the repro as minimal as possible- you probably don't need a class with
newhere if instead you could just use a simple pipe that nests the item in a tuple or similar. -
.snapdefines an inline snapshot. You should start by writing.snap()and when the test first runs, it will autopopulate the expected value into your test file.
Thanks so much for taking a look! 🥰
- This cannot be reproduced if the
or("null")condition is omitted in the outer object - Even though the inner default isn't being used in the repro, without it the test passes
- I think the morph is not being queued based on my understanding
New Test;
it("nested morphed", () => {
const Box = (of: unknown) => ({ of })
const T = type({ box2: type("string|null").pipe(Box).default(null) })
// const U = type({ box1: T.pipe(Box) }) // this passes the test
const U = type({ box1: T.or("null").pipe(Box) }) // this does not
const out = U({ box1: { box2: null } })
attest(out).snap({ box1: { of: { box2: { of: null } } } })
})
Test output with some added console logs
console.log("applyQueuedMorphs", this.path, this.queuedMorphs)andconsole.log("queueMorphs", this.path, morphs)respectively
queueMorphs [] [ [Function: Box] ]
applyQueuedMorphs [] [
{ path: ReadonlyPath(0) [ cache: {} ], morphs: [ [Function: Box] ] }
]
applyQueuedMorphs [] []
queueMorphs [] [ [Function (anonymous)] ]
applyQueuedMorphs [] [
{
path: ReadonlyPath(0) [ cache: {} ],
morphs: [ [Function (anonymous)] ]
}
]
queueMorphs [ 'box1' ] [ [Function: Box] ]
applyQueuedMorphs [] [
{
path: ReadonlyPath(1) [ 'box1', cache: {} ],
morphs: [ [Function: Box] ]
}
]
applyQueuedMorphs [ 'box1' ] []
queueMorphs [ 'box1' ] [ [Function (anonymous)] ]
queueMorphs [ 'box1' ] [ [Function: Box] ]
queueMorphs [] [ [Function (anonymous)] ]
applyQueuedMorphs [] [
{
path: ReadonlyPath(1) [ 'box1', cache: {} ],
morphs: [ [Function (anonymous)] ]
},
{
path: ReadonlyPath(1) [ 'box1', cache: {} ],
morphs: [ [Function: Box] ]
},
{
path: ReadonlyPath(0) [ cache: {} ],
morphs: [ [Function (anonymous)] ]
}
]
applyQueuedMorphs [ 'box1' ] []
applyQueuedMorphs [ 'box1' ] []
applyQueuedMorphs [] []
1) nested morphed
0 passing (57ms)
1 failing
1) defaults
parsing and traversal
nested morphed:
Expected values to be strictly deep-equal:
+ actual - expected
{
box1: {
of: {
+ box2: null
- box2: {
- of: null
- }
}
}
}
+ expected - actual
{
"box1": {
"of": {
- "box2": [null]
+ "box2": {
+ "of": [null]
+ }
}
}
}
I'll try to debug more, would appreciate some insight in which files would be relevant to look into. Thanks
The fact that it seems specific to unions like this makes me think potentially it has to do with discrimination logic. To confirm this suspicion you could try enabling jitless mode:
https://arktype.io/docs/configuration#jitless
and retest to see if the bug still occurs (or just use a scope inline in the test).
If it works without jitless mode, that tells us its almost certainly a bug with the way the branch jumping works for discriminated unions, since that optimization doesn't occur outside JIT-precompiled code.
Beyond that, I think a lot of the relevant logic is in ark/schema/structure/optional.ts which handles defaults and also has a bunch of dedicated logic for computing morph results for default values.
leaving this as a note here, even more minimal repro
it("nested morphed with unions", () => {
const Box = <T>(of: T) => ({ of })
const T = type({ box2: type("string").pipe(Box<string>) })
const U = type({ box1: T.or("null").pipe(Box<typeof T.infer | null>) })
const out = U({ box1: { box2: "cookies" } })
attest(out).snap({ box1: { of: { box2: { of: "cookies" } } } })
})
- this does happen even with jitless
- narrowed down the issue to this line, before this line the inner morph is there after this line it vanishes
https://github.com/arktypeio/arktype/blob/ff7db8b61004f8de82089a22f4cb4ab5938c2a97/ark/schema/roots/root.ts#L449
I think branch.hasKind("morph") is the issue, it seems to return false even though the branch has a morph, I tested this by console logging both hasKind and branch.expression
looking into the hasKind function it seems like this.kind for the branch is intersection, not sure where the actual bug lies, the hasKind function or setting of the kind itself
Relevant Logs:
a677 [ [Function: Box] ]
a543 [ '{ box2: (In: string) => Out<unknown> }', 'null' ]
this.kind intersection
b12 false { box2: (In: string) => Out<unknown> }
Source for logs:
rawPipeOnce(morph: Morph): BaseRoot {
if (hasArkKind(morph, "root")) return this.toNode(morph)
console.log(
"a543",
this.branches.map(b => b.expression)
)
const x = this.distribute(
branch =>
(
(console.log("b12", branch.hasKind("morph"), branch.expression),
branch.hasKind("morph"))
) ?
this.$.node("morph", {
in: branch.inner.in as never,
morphs: [...branch.morphs, morph]
})
: this.$.node("morph", {
in: branch,
morphs: [morph]
}),
this.$.parseSchema
)
console.log(
"a544",
x.branches.map(b => b.expression)
)
return x
}
protected _pipe(...morphs: Morph[]): BaseRoot {
console.log("a677", morphs)
const result = morphs.reduce<BaseRoot>(
(acc, morph) => acc.rawPipeOnce(morph),
this
)
console.log("a674", result.expression)
return this.$.finalize(result)
}
Hello, I have encountered a bug involving union types and pipes.
I don't have much experience in arktype, but I believe that narrow should not change the output value.
I think that my bug and this issue may share the same cause, so I'll share my minimum repro here.
- ArkType Version: 2.1.22
import { type } from 'arktype';
const A = type("number");
const B = A.array().or(A.pipe((v) => [v]));
const BX = type([B]);
const BY = type([B, B]);
// OK, prints [1]
console.log(B(1));
// OK, prints [[1]]
console.log(BX([1]));
// OK, prints [[1]]
console.log(BX.narrow(() => true)([1]));
// OK, prints false
console.log(BY.allows([1]));
// OK, prints [[1]]
console.log(type.or(BX, BY)([1]));
// NOT OK, prints [1], expected [[1]]
console.log(type.or(BX, BY).narrow(() => true)([1]));