Skip to content

Variant / Discriminated union based on a subkey #1108

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
selrond opened this issue Mar 24, 2025 · 19 comments
Open

Variant / Discriminated union based on a subkey #1108

selrond opened this issue Mar 24, 2025 · 19 comments
Assignees
Labels
enhancement New feature or request workaround Workaround fixes problem

Comments

@selrond
Copy link

selrond commented Mar 24, 2025

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
Copy link
Author

selrond commented Mar 24, 2025

This has been brought up in Zod as well: colinhacks/zod#1868

@fabian-hiller
Copy link
Owner

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 fabian-hiller self-assigned this Mar 25, 2025
@fabian-hiller fabian-hiller added the enhancement New feature or request label Mar 25, 2025
@fabian-hiller
Copy link
Owner

In the meantime you can just write:

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

@fabian-hiller fabian-hiller added the workaround Workaround fixes problem label Mar 25, 2025
@selrond
Copy link
Author

selrond commented Mar 25, 2025

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

@fabian-hiller
Copy link
Owner

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?

@muningis
Copy link
Contributor

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.

@selrond
Copy link
Author

selrond commented Mar 25, 2025

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

@selrond
Copy link
Author

selrond commented Mar 25, 2025

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

@muningis
Copy link
Contributor

muningis commented Mar 25, 2025

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
Copy link
Contributor

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
Copy link
Contributor

#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.

@fabian-hiller
Copy link
Owner

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
Copy link
Owner

@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.

@muningis
Copy link
Contributor

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

@selrond
Copy link
Author

selrond commented Mar 28, 2025

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.

@vladshcherbin
Copy link
Contributor

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 😭

@fabian-hiller
Copy link
Owner

@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().

@lkwr
Copy link

lkwr commented Apr 2, 2025

@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

@muningis
Copy link
Contributor

muningis commented Apr 2, 2025

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request workaround Workaround fixes problem
Projects
None yet
Development

No branches or pull requests

5 participants