ciscoheat / sveltekit-superforms

Making SvelteKit forms a pleasure to use!
https://superforms.rocks
MIT License
2.11k stars 62 forks source link

accept union type in generic component #429

Open macmillen opened 3 months ago

macmillen commented 3 months ago

Is your feature request related to a problem? Please describe.

To have generic components it's sometimes neccessary to pass down union types where only an intersection of the type is equal.

Demo: https://www.sveltelab.dev/y7gmqoire0bl3u4?files=.%2Fsrc%2Flib%2Funion.svelte

Union component:

  export let superForm:
    | SuperForm<Infer<typeof eventSchema>>
    | SuperForm<Infer<typeof admissionSchema>>
    | SuperForm<Infer<typeof eventTemplateSchema>>;

Schemas:

export const eventSchema = z.object({
    name: z.string()
});
export const eventTemplateSchema = z.object({
    name: z.string(),
    id: z.string()
});
export const admissionSchema = z.object({
    name: z.string()
});

Generic input component

  export let superForm: SuperForm<T> | undefined = undefined;

But TypeScript complains if the schema types differ even slightly from each other.

Type 'SuperForm<{ name: string; }> | SuperForm<{ name: string; }> | SuperForm<{ name: string; id: string; }>' is not assignable to type 'SuperForm<{ name: string; }> | undefined'.
  Type 'SuperForm<{ name: string; id: string; }>' is not assignable to type 'SuperForm<{ name: string; }>'.
    The types of 'options.onUpdate' are incompatible between these types.
      Type '((event: { form: SuperValidated<{ name: string; id: string; }, any, { name: string; id: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown) | undefined' is not assignable to type '((event: { form: SuperValidated<{ name: string; }, any, { name: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown) | undefined'.
        Type '(event: { form: SuperValidated<{ name: string; id: string; }, any, { name: string; id: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown' is not assignable to type '(event: { form: SuperValidated<{ name: string; }, any, { name: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown'.

Describe the solution you'd like It would be helpful to make the generics work with unions somehow

ciscoheat commented 3 months ago

Does it work to put the union in the schemas, or in Infer?

SuperForm<Infer<typeof eventSchema | typeof admissionSchema>>
SuperForm<Infer<typeof eventSchema> | Infer<typeof admissionSchema>>
macmillen commented 3 months ago

Unfortunately not. I tried all variations 😕

macmillen commented 3 months ago

I updated the demo with your suggestion but it still fails

Error: Type 'SuperForm<{ name: string; desc: string | null; }, any>' is not assignable to type 'SuperForm<Infer<ZodObject<{ name: ZodString; id: ZodString; }, "strip", ZodTypeAny, { name: string; id: string; }, { name: string; id: string; }> | ZodObject<...>>>'.
  Types of property 'options' are incompatible.
    Type 'Partial<{ id: string; applyAction: boolean; invalidateAll: boolean | "force"; resetForm: boolean | (() => boolean); scrollToError: boolean | ScrollIntoViewOptions | "auto" | "smooth" | "off"; ... 24 more ...; legacy: boolean; }>' is not assignable to type 'Partial<{ id: string; applyAction: boolean; invalidateAll: boolean | "force"; resetForm: boolean | (() => boolean); scrollToError: boolean | ScrollIntoViewOptions | "auto" | "smooth" | "off"; ... 24 more ...; legacy: boolean; }> | Partial<...>'.
      Type 'Partial<{ id: string; applyAction: boolean; invalidateAll: boolean | "force"; resetForm: boolean | (() => boolean); scrollToError: boolean | ScrollIntoViewOptions | "auto" | "smooth" | "off"; ... 24 more ...; legacy: boolean; }>' is not assignable to type 'Partial<{ id: string; applyAction: boolean; invalidateAll: boolean | "force"; resetForm: boolean | (() => boolean); scrollToError: boolean | ScrollIntoViewOptions | "auto" | "smooth" | "off"; ... 24 more ...; legacy: boolean; }>'. Two different types with this name exist, but they are unrelated.
        Types of property 'onUpdate' are incompatible.
          Type '((event: { form: SuperValidated<{ name: string; desc: string | null; }, any, { name: string; desc: string | null; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown) | undefined' is not assignable to type '((event: { form: SuperValidated<{ name: string; }, any, { name: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown) | undefined'.
            Type '(event: { form: SuperValidated<{ name: string; desc: string | null; }, any, { name: string; desc: string | null; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown' is not assignable to type '(event: { form: SuperValidated<{ name: string; }, any, { name: string; }>; formEl: HTMLFormElement; formElement: HTMLFormElement; cancel: () => void; result: Required<...>; }) => unknown'. (ts)

<Union {superForm} />
macmillen commented 3 months ago

I though maybe a way to solve this would be to use generics but it also doesn't work. This time the field prop complains. (See demo - component name is union2.svelte)

<script lang="ts" generics="T extends { name: string }">
    import type { SuperForm } from 'sveltekit-superforms/client';
    import Input from './input.svelte';

    export let superForm: SuperForm<T>;

    $: ({ form } = superForm);
</script>

<Input {superForm} field="name" />
Type '"name"' is not assignable to type 'FormPathLeaves<T>'.
  Type '"name"' is not assignable to type 'StringPath<T, { filter: "leaves"; objAppend: never; path: ""; type: any; }>'.
ts
ciscoheat commented 3 months ago

You can use either z.union, or define the type as you did:

export const schema = z.union([eventTemplateSchema, eventSchema, admissionSchema]);

export type UnionSchema =
    | Infer<typeof eventSchema>
    | Infer<typeof admissionSchema>
    | Infer<typeof eventTemplateSchema>;

Then use the superForm type parameter to get the correct type for the schema:

+page.svelte

<script lang="ts">
    import Union from './union.svelte';
    import { superForm as _superForm } from '$lib/index.js';
    import type { UnionSchema } from './schemas.js';

    export let data;

    const superForm = _superForm<UnionSchema>(data.form);
</script>

<Union {superForm} />

union.svelte

<script lang="ts">
    import type { SuperForm } from '$lib/index.js';
    import { type UnionSchema } from './schemas.js';
    import Input from './input.svelte';

    export let superForm: SuperForm<UnionSchema>;
</script>

<Input {superForm} field="name" />
macmillen commented 3 months ago

Thank you so much for your help!

The problem with that is that if we have e.g. 3 different pages I don't want to use UnionSchema everywhere but just the eventSchema for example.

I think a great solution would be (if that's somehow possible) if we could use generics or maybe create something like a PartialSchema<> type helper that takes a sub set of the schema as an argument:

page 1:

<script lang="ts">
  export let superForm: SuperForm<Infer<typeof eventSchema>>;
</script>

<Union {superForm} />

page 2:

<script lang="ts">
  export let superForm: SuperForm<Infer<typeof admissionSchema>>;
</script>

<Union {superForm} />

union.svelte

<script lang="ts">
  import type { SuperForm } from 'sveltekit-superforms';
  import Input from './input.svelte';

  export let superForm: SuperForm<PartialSchema<{ name: string }>>;
</script>

<Input {superForm} field="name" />
fehnomenal commented 1 month ago

Yes, please I also need this. The workaround does not really work in my case (as it complains about properties in both schemas but unrelated to the common fields of interest).

I split my form into smaller parts to reuse them on different pages that are similar but not identical. One small part is responsible for the selection of a range of product numbers (productNoFrom and productNoTo). Now I have schemas filterProductsSchema and exportProductsSchema which both contain those fields and are used on different pages.

I would love to exactly the same as the comment right above this one:

<!-- ProductNumbersRangeSelect.svelte -->
<script lang="ts">
  import type { SuperForm } from 'sveltekit-superforms';

  export let form: SuperForm<{ productNoFrom: string, productNoTo: string }>;
</script>

<Input {form} name="productNoFrom" />
<Input {form} name="productNoTo" />

And call out to this component from both pages:

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { superForm } from 'sveltekit-superforms';

  export let data;

  const filterForm = superForm(data.filterForm);
</script>

<ProductNumbersRangeSelect form={filterForm} />
<!-- src/routes/export/+page.svelte -->
<script lang="ts">
  import { superForm } from 'sveltekit-superforms';

  export let data;

  const exportForm = superForm(data.exportForm);
</script>

<ProductNumbersRangeSelect form={exportForm} />