arktype icon indicating copy to clipboard operation
arktype copied to clipboard

[Bug] Nested morphs not being applied correctly

Open vanillacode314 opened this issue 3 months ago • 11 comments

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 }

vanillacode314 avatar Sep 24 '25 16:09 vanillacode314

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 🙏

ssalbdivad avatar Sep 24 '25 17:09 ssalbdivad

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.

vanillacode314 avatar Sep 24 '25 17:09 vanillacode314

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.

ssalbdivad avatar Sep 24 '25 17:09 ssalbdivad

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

vanillacode314 avatar Sep 24 '25 17:09 vanillacode314

This looks like it works, but a couple suggestions:

  1. Try to make the repro as minimal as possible- you probably don't need a class with new here if instead you could just use a simple pipe that nests the item in a tuple or similar.

  2. .snap defines 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! 🥰

ssalbdivad avatar Sep 24 '25 17:09 ssalbdivad

  • 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) and console.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

vanillacode314 avatar Sep 24 '25 19:09 vanillacode314

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.

ssalbdivad avatar Sep 24 '25 23:09 ssalbdivad

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" } } } })
})

vanillacode314 avatar Sep 25 '25 04:09 vanillacode314

  • 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

vanillacode314 avatar Sep 25 '25 05:09 vanillacode314

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)
}

vanillacode314 avatar Sep 25 '25 05:09 vanillacode314

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]));

123jimin avatar Oct 03 '25 06:10 123jimin