fabian-hiller / valibot

The modular and type safe schema library for validating structural data 🤖
https://valibot.dev
MIT License
6.11k stars 195 forks source link

Is it possible to combine `GenericSchema` with `variant`? #842

Open AndreiCravtov opened 1 month ago

AndreiCravtov commented 1 month ago

I am trying to make a schema-constructor corresponding to a Rust-style Result type, more concretely it has this type:

type Ok<T> = { success: true; data: T }
type Err<E> = { success: false; error: E }
type Result<T, E> = Ok<T> | Err<E>

I can make a schema-constructor like this:

const ResultSchema = <TSchema extends GenericSchema, ESchema extends GenericSchema>(dataSchema: TSchema, errorSchema: ESchema) => union([
    object({ success: literal(true), data: dataSchema }),
    object({ success: literal(false), error: errorSchema }),
])

And that works, but this is a discriminated union so variant would be more appropriate. However when I try to use it

const ResultSchema = <TSchema extends GenericSchema, ESchema extends GenericSchema>(dataSchema: TSchema, errorSchema: ESchema) => variant("success", [
    object({ success: literal(true), data: dataSchema }),
    object({ success: literal(false), error: errorSchema }),
])

I get these type errors

Type 'ObjectSchema<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, undefined>' is not assignable to type 'VariantOption<"success">'.
  Type 'ObjectSchema<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, undefined>' is not assignable to type 'ObjectSchema<Record<"success", BaseSchema<unknown, unknown, BaseIssue<unknown>>>, ErrorMessage<ObjectIssue> | undefined>'.
    The types returned by '_run(...)' are incompatible between these types.
      Type 'Dataset<{ [TKey in keyof WithReadonly<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, WithQuestionMarks<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, InferEntriesOutput<...>>>]: WithReadonly<...>[TKey]; }, ObjectIssue | InferObjectIssue<...>>' is not assignable to type 'Dataset<{ success: unknown; }, BaseIssue<unknown> | ObjectIssue>'.
        Type 'TypedDataset<{ [TKey in keyof WithReadonly<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, WithQuestionMarks<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, InferEntriesOutput<...>>>]: WithReadonly<...>[TKey]; }, ObjectIssue | InferObjectIssue<...>>' is not assignable to type 'Dataset<{ success: unknown; }, BaseIssue<unknown> | ObjectIssue>'.
          Type 'TypedDataset<{ [TKey in keyof WithReadonly<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, WithQuestionMarks<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, InferEntriesOutput<...>>>]: WithReadonly<...>[TKey]; }, ObjectIssue | InferObjectIssue<...>>' is not assignable to type 'TypedDataset<{ success: unknown; }, BaseIssue<unknown> | ObjectIssue>'.
            Property 'success' is missing in type '{ [TKey in keyof WithReadonly<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, WithQuestionMarks<{ readonly success: LiteralSchema<true, undefined>; readonly data: TSchema; }, InferEntriesOutput<...>>>]: WithReadonly<...>[TKey]; }' but required in type '{ success: unknown; }'.

Not sure what any of this means, but I'm suspecting there is some small detail I missed or some generic I need to use. Now, I can just use union but I am curious why variant doesn't work.

fabian-hiller commented 1 month ago

It seems that the type check fails when a generic schema is involved. Unfortunately, I do not have a solution for you at the moment. The only workaround for now is to cast the type and ignore the TS errors.

import * as v from 'valibot';

const ResultSchema = <
  DataSchema extends v.GenericSchema,
  ErrorSchema extends v.GenericSchema,
>(
  dataSchema: DataSchema,
  errorSchema: ErrorSchema
) =>
  v.variant('success', [
    // @ts-expect-error
    v.object({ success: v.literal(true), data: dataSchema }),
    // @ts-expect-error
    v.object({ success: v.literal(false), error: errorSchema }),
  ]) as v.VariantSchema<
    'success',
    // @ts-expect-error
    [
      v.ObjectSchema<
        { success: v.LiteralSchema<true, undefined>; data: DataSchema },
        undefined
      >,
      v.ObjectSchema<
        { success: v.LiteralSchema<false, undefined>; data: ErrorSchema },
        undefined
      >,
    ],
    undefined
  >;
AndreiCravtov commented 1 month ago

I see, thank-you. I asked this purely out of curiosity, but there just seems to be an enigmatic type-error deep-down that isn't yet explainable.

fabian-hiller commented 1 month ago

I may look into this at a later date, but I am honestly not sure if this is fixable.

Gregoor commented 1 week ago

I'm excited to switch from zod to valibot, since the former is too bloated for my use case, and where Valibot's variant is composable, zod's discriminatedUnion isn't.

Anyway, this is a bit of a blocker issue for me, as I'm not seeing what I could cast my schema to.

With zod I had this:

const baseLogicalOperation = idObject.extend({
  type: z.literal("LogicalOperation"),
  operator: z.enum(["and", "or"]),
  negated: z.boolean(),
});
export type LogicalOperation = z.infer<typeof baseLogicalOperation> & {
  conditions: Condition[];
};
const logicalOperation = baseLogicalOperation.extend({
  conditions: z.lazy(() => z.array(condition)),
}) as z.ZodType<LogicalOperation>;

With vali I'm trying

export type LogicalOperation = {
  id: string;
  type: "LogicalOperation";
  operator: "and" | "or";
  negated: boolean;
  conditions: Condition[];
};
const logicalOperation: v.GenericSchema<LogicalOperation> = v.object({
  ...idObject.entries,
  type: v.literal("LogicalOperation"),
  operator: v.picklist(["and", "or"]),
  negated: v.boolean(),
  conditions: v.lazy(() => v.array(condition)),
});

export const condition = v.variant("type", [
  answerComparison,
  logicalOperation,
]);

where I don't love the field repetition, though I could live with it. The bigger issue is that I can indeed not use GenericSchema in the variant.

fabian-hiller commented 1 week ago

Does this work for you?

import * as v from 'valibot';

type Condition = {
  // ...
};

const answerComparison = v.object({
  type: v.literal('AnswerComparison'),
});

const logicalOperation = v.object({
  type: v.literal('LogicalOperation'),
  operator: v.picklist(['and', 'or']),
  negated: v.boolean(),
  conditions: v.lazy(() => v.array(condition)),
});

const condition: v.GenericSchema<any> = v.variant('type', [
  answerComparison,
  logicalOperation,
]);
Gregoor commented 1 week ago

Thanks for the quick reply! Initially I was skeptical because of any, but then it did lead me to play around with it some more, and I this version here now works for me:

export type LogicalOperation = {
  id: string;
  type: "LogicalOperation";
  operator: "and" | "or";
  negated: boolean;
  conditions: Condition[];
};
const logicalOperation = v.object({
  ...idObject.entries,
  type: v.literal("LogicalOperation"),
  operator: v.picklist(["and", "or"]),
  negated: v.boolean(),
  conditions: v.lazy((): v.GenericSchema<Condition[]> => v.array(condition)),
});

export const condition = v.variant("type", [
  answerComparison,
  logicalOperation,
]);
export type Condition = LogicalOperation | AnswerComparison;

I'm still a bit hesitant to make the switch because I now have a number of type definitions that repeat what schemas already state and I'm worried I'll make a mistake and the two will desync. But I think I'll risk it nonetheless, because of bundle size and the beauty of nested variants. Thank you!

fabian-hiller commented 1 week ago

any was an accident. I had planned to put Condition in there. My bad.