valibot icon indicating copy to clipboard operation
valibot copied to clipboard

Variant / Discriminated union based on a subkey

Open selrond opened this issue 8 months ago • 19 comments

Let’s say I’m validating data of this shape:

{
  "id": "4567",
  "value": {
    "streetAddress": "14720 Honore Avenue",
    "secondaryAddress": "",
    "city": "Harvey",
    "postalCode": "60426",
    "state": "IL",
    "country": "US",
  },
  "fieldConfig": {
    "id": "1234",
    "key": "address",
    "type": "address",
    "label": "Address",
    "options": {
      "optionA": true
    },
  },
},

The value shape is dependent on the field type.

Is there an ergonomic way to validate such shape?

Something like this (not working, just to give an idea):

const FieldSchema = v.variant('fieldConfig.type', [
  AddressFieldSchema,
  ...
])

I don’t insist on a dot notation API, if there’s an ergonomic way to do it already, please do tell.

selrond avatar Mar 24 '25 18:03 selrond

This has been brought up in Zod as well: https://github.com/colinhacks/zod/issues/1868

selrond avatar Mar 24 '25 22:03 selrond

I will consider supporting an API like this in the long run:

const FieldSchema = v.variant(['fieldConfig', 'type'], [
  AddressFieldSchema,
  ...
])

Would you be interested in investigating the implementation? The hard part will be getting the types right and implementing the validation without adding a lot of code to the bundle size.

fabian-hiller avatar Mar 25 '25 03:03 fabian-hiller

In the meantime you can just write:

const FieldSchema = v.variant('fieldConfig', [
  AddressFieldSchema,
  ...
])

fabian-hiller avatar Mar 25 '25 03:03 fabian-hiller

With this workaround, the types aren’t narrowed properly though

selrond avatar Mar 25 '25 09:03 selrond

If this is the case, it is a limitation of TypeScript and not Valibot. Can you try defining such a type with pure TypeScript types?

fabian-hiller avatar Mar 25 '25 13:03 fabian-hiller

Surely possible with typescript by using template literal types and accessing object deeply. react-hook-form has something similar.

However types performance is absolute tragedy with such usage.

muningis avatar Mar 25 '25 13:03 muningis

@fabian-hiller just tried it and indeed it’s a TS limitation... Here is a reproduction

selrond avatar Mar 25 '25 13:03 selrond

I suppose there’s no difference between v.variant & v.union in this case, is there @fabian-hiller?

The best thing to do here is just use type guards, I think

selrond avatar Mar 25 '25 13:03 selrond

Managed to define such type with Typescript:

https://www.typescriptlang.org/play/?#code/C4TwDgpgBAEghgZwNIRAHgPICMBWUIAewEAdgCYJQD2uEAxsADRQoj5GkVQLABOAliQDmAPigBeFqnbFylANaoqAMyjY8Afih8ArtABcUZXAA2CCAG4AUFdCQoAJXoSoAbytQoAbXmGeA4QBdPz5BISgAH0dnKJ0SeRIqAHcSKwBfGztoAHEIYABlUOEAIRAABThgAAtMXBlOSic6Zgrq+rluItEXVqr2rgADABJXQWUIXigAGTSAOhGxicc0gY8oLSn+hSVVdTXPLXUvKcCt6Lp9z3WoXIKu0t7anGPTgDJz5gcRS89DI5Ozv4wj8rodcC8Qb8oCQIAA3CaXQww+G8NaGXpnRQgFRqXCXMHPXqnQiyLhA4QggleIkgpFwhFXKB0lHWWzgaAAYSoAFswHBeBBCgEhA9KjV1Gcmi0xYCuswAKIESAMCBkABqpj0suFYkkGJJDSgw1GJHGkxm8xNZuWqwO00xO1xOHxTpekvolKgXN5-MF93KYqebveUscCqV9GI6s1EG+jKh-2JHA65KEkOuibOiuVUY1Ji1Wl0BiMpnM6eZDKuFdRUP1ya4WJxe0ZVKJ2uB8Yz4LbBo62cjqrzBe0vC1hmMZggtOh9JrVZnLMy7KgADFBNGBHASMA0AAVd3NKB10mUVPhnODmPt4S6qBrfe9rhbtgu718gVCsKi6p76XVc8DtG+axmcRZ3p2Wj7tOyJLIiC4TKybL2AAclQZAQBqm7bpQkjuJ4ZCVHAhiuFA3KCEOEBIjo3JYAhpFwAQFFUTRSwZJ4iTocR2jsoYABEJDUbRvC8VAGRpJEbhrARwBEW4pGCFMpBCNUzFCRY9EEIpwgqdCgmsWsHGUXJWR8amIliTYSHQLuEA8AAgi4a7kJh-BbjueFQNJskkWRJBMbpLG8Op3IMf5AmBaJBloUZJEmVA-F6cJkXiVEHleVxvlacpVSqXRIWaUpOnhUJkXsdFXFxbxZnJcw-HRbMWS8bVZkiOpAD0bVQDoYDaFQUBVBM0D8MAUD8JQcBYCY0DAH1iy8G1yjrkAA

It's possible to deep-check value with path and expected value, and get required value.

But I can't get function to do it's job

const node = { data: { minValue: 5, maxValue: 7 }, node: { type: "number" } } as NodeVariants;
node.node.type; // string | number

const deepVariant = <Obj extends Rec, Path extends string, ExpectedValue extends GetStringByPath<Obj, Path>>(obj: Obj, path: Path): FindVariant<Obj, Path, ExpectedValue> => {
  return obj as FindVariant<Obj, Path, ExpectedValue>
}

const n = deepVariant(node, "node.type");
n.node.type // number

this one doesn't narrow down

muningis avatar Mar 25 '25 13:03 muningis

I've "semi" working (partial replication of variants, no nested variants yet, and few other things skipped for now), and it would be possible to get an union type out of it.

However, to even use that discriminated union, it would probably require helper function - but in best case scenario it would be a matcher function (like a switch)

muningis avatar Mar 25 '25 18:03 muningis

https://github.com/fabian-hiller/valibot/pull/1112

  1. I'm pretty positive, that due to TS limitations, we can't use that value without matchVariation
  2. Still have to check performance.

muningis avatar Mar 25 '25 19:03 muningis

I suppose there’s no difference between v.variant & v.union in this case, is there @fabian-hiller?

I would probably still use variant as it may be faster and return better issues. We will ship a performance improvement for this cases with the next version. See PR #1110.

fabian-hiller avatar Mar 28 '25 03:03 fabian-hiller

@muningis I am very sorry for being so slow to respond. I have almost no time at the moment. I will try to catch up in the next few days.

fabian-hiller avatar Mar 28 '25 03:03 fabian-hiller

No worries. It's just experimental POC PR just to even get idea if it's possible.

muningis avatar Mar 28 '25 08:03 muningis

I’ve been thinking about it more lately, and I think implementing something like distributed union with a nested discriminant would be swimming against the tide.

It doesn’t work in TypeScript yet, and there’s an open issue for it as well. I’d say it would be wisest to wait until TypeScript expands the narrowing rules.

The feature itself — in my case — would just be making up for a bad API design, and creating type guards manually is still a great option.

@fabian-hiller feel free to close this issue.

selrond avatar Mar 28 '25 10:03 selrond

I'd also love to have something for this. Currently using pattern:

const propertiesSchema = variant('name', [
  looseObject({
    name: literal('body_type'),
    value: picklist([
      // ...
    ])
  }),
  looseObject({
    name: literal('brand'),
    value: string()
  }),
  looseObject({
    name: literal('condition'),
    value: picklist([
      // ...
    ])
  })
])

exporting separate types:

export type BodyType = Extract<InferOutput<typeof propertiesSchema>, { name: 'body_type' }>
export type Brand = Extract<InferOutput<typeof propertiesSchema>, { name: 'brand' }>

using:

const bodyType = (car.properties.find(({ name }) => name === 'body_type') as BodyType).value

which feels so dirty 😭

vladshcherbin avatar Mar 29 '25 16:03 vladshcherbin

@vladshcherbin I think your code is not related to this issue or am I wrong? This seems more like a TS inference limitation when using array.find().

fabian-hiller avatar Mar 31 '25 01:03 fabian-hiller

@vladshcherbin It's working without deconstruction. Tested on [email protected]

Not working 🚫:

const bodyType = car.properties.find(({ name }) => name === 'body_type')?.value
// string | undefined

Working ✅:

const bodyType = car.properties.find((property) => property.name === 'body_type')?.value
// "picklist_1" | "picklist_2" | undefined

lkwr avatar Apr 02 '25 11:04 lkwr

@vladshcherbin - that would require full code shown. However as BodyType is already widening the type.

muningis avatar Apr 02 '25 17:04 muningis

Hi, @selrond. I'm Dosu, and I'm helping the Valibot team manage their backlog. I'm marking this issue as stale.

Issue Summary

  • The issue involves finding an ergonomic way to validate data structures where value shape depends on fieldConfig.type.
  • @fabian-hiller suggested an API and workaround, but you noted TypeScript limitations in type narrowing.
  • @muningis explored solutions but encountered performance issues.
  • You suggested waiting for future TypeScript updates due to current limitations.
  • @fabian-hiller mentioned potential performance improvements in upcoming TypeScript versions.

Next Steps

  • Please let me know if this issue is still relevant with the latest version of Valibot by commenting here.
  • If there is no further activity, this issue will be automatically closed in 30 days.

Thank you for your understanding and contribution!

dosubot[bot] avatar Jul 02 '25 16:07 dosubot[bot]