Type-safe discriminated union support for SvelteKit remote function form fields.
This library provides two complementary tools for working with discriminated unions in SvelteKit forms:
discriminated() - A wrapper function that enables proper TypeScript type narrowing on form field objectsFieldVariants - A component that renders variant-specific form sections with CSS-only visibility, supporting progressive enhancement (works without JavaScript)The implementation prioritises:
This library exposes existing SvelteKit form behaviour with improved typing for discriminated unions. Runtime overhead is minimal - just a thin proxy wrapper.
npm install sveltekit-discriminated-fields
The FieldVariants component provides declarative variant rendering with full progressive enhancement support. It uses CSS-only visibility toggling via form:has() selectors, so forms work identically with or without JavaScript enabled.
Given a SvelteKit remote function using Zod (this library also works with Valibot or other validation libraries):
// data.remote.ts
import { z } from "zod";
import { form } from "$app/server";
const shapeSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("circle"), radius: z.number() }),
z.object({
kind: z.literal("rectangle"),
width: z.number(),
height: z.number(),
}),
z.object({ kind: z.literal("point") }),
]);
export const shapeForm = form(shapeSchema, async (data) => {
// handle submission
});
Use FieldVariants to render variant-specific fields:
<script lang="ts">
import { shapeForm } from './data.remote';
import { FieldVariants } from 'sveltekit-discriminated-fields';
</script>
<form {...shapeForm}>
<select {...shapeForm.fields.kind.as('select')}>
<option value="">Select a shape...</option>
<option value="circle">Circle</option>
<option value="rectangle">Rectangle</option>
<option value="point">Point</option>
</select>
<FieldVariants fields={shapeForm.fields} key="kind">
{#snippet fallback(props)}
<p {...props}>Please select a shape type above.</p>
{/snippet}
{#snippet circle(shape)}
<label {...shape}>
Radius: <input {...shape.fields.radius.as('number')} />
</label>
{/snippet}
{#snippet rectangle(shape)}
<div {...shape}>
<input {...shape.fields.width.as('number')} placeholder="Width" />
<input {...shape.fields.height.as('number')} placeholder="Height" />
</div>
{/snippet}
{#snippet point(shape)}
<p {...shape}>Point has no additional fields.</p>
{/snippet}
</FieldVariants>
<button type="submit">Submit</button>
</form>
Each variant snippet receives a single argument that mirrors how forms work in SvelteKit:
{...shape} - Adds the data-fv attribute for CSS visibilityshape.fields.radius - Provides the variant-specific form fieldsThis pattern is consistent with how you use forms: <form {...form}> + form.fields.x.
Each snippet receives correctly narrowed fields for that variant - TypeScript knows shape.fields.radius exists in the circle snippet but not in rectangle. Only valid discriminator values are accepted.
Snippets only receive fields specific to that variant. Fields common to all variants (same name and type) should be rendered outside FieldVariants to prevent accidental duplicate inputs. Fields shared by some but not all variants, or with differing types across variants, produce compile-time errors.
For radio button discriminators, you must use the discriminated() wrapper. The raw SvelteKit field object's .as("radio", value) method doesn't work with discriminated unions (causes a static error). The wrapped version is type-safe - only valid discriminator values are accepted:
<script lang="ts">
import { shapeForm } from './data.remote';
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
const shape = $derived(discriminated(shapeForm.fields, 'kind'));
</script>
<form {...shapeForm}>
<fieldset>
<label><input {...shape.fields.kind.as("radio", "circle")} /> Circle</label>
<label><input {...shape.fields.kind.as("radio", "rectangle")} /> Rectangle</label>
<label><input {...shape.fields.kind.as("radio", "point")} /> Point</label>
</fieldset>
<FieldVariants fields={shapeForm.fields} key="kind">
{#snippet fallback(props)}
<p {...props}>Select a shape type</p>
{/snippet}
{#snippet circle(shape)}
<label {...shape}>
Radius: <input {...shape.fields.radius.as('number')} />
</label>
{/snippet}
<!-- other snippets -->
</FieldVariants>
</form>
See the radio-form example for a complete working example.
For select elements, you can use .as("option", value) for type-safe option values. This is optional - you can still use value="..." directly if you prefer:
<select {...shape.fields.kind.as("select")}>
<!-- Type-safe: typos caught at compile time -->
<option {...shape.fields.kind.as("option")}>Select a shape...</option>
<option {...shape.fields.kind.as("option", "circle")}>Circle</option>
<option {...shape.fields.kind.as("option", "rectangle")}>Rectangle</option>
<!-- Also works: standard HTML (no type checking) -->
<option value="point">Point</option>
</select>
as("option") returns { value: "" } for the placeholder optionas("option", "circle") returns { value: "circle" } with type checkingFieldVariants uses form:has() CSS selectors to show/hide variant sections based on the selected discriminator value. This works automatically for any layout - the discriminator input and variant sections can be anywhere within the same <form>.
<form {...shapeForm}>
<div class="header">
<select {...shapeForm.fields.kind.as('select')}>
<!-- options -->
</select>
</div>
<div class="body">
<!-- Works regardless of DOM structure -->
<FieldVariants fields={shapeForm.fields} key="kind">
<!-- snippets -->
</FieldVariants>
</div>
</form>
See the selector-form example for select elements or selector-radio-form example for radio buttons.
The discriminated union doesn't need to be the top-level schema. It can be nested within a larger object:
const orderSchema = z.object({
orderId: z.string(),
shipping: z.discriminatedUnion("method", [
z.object({ method: z.literal("pickup"), store: z.string() }),
z.object({ method: z.literal("delivery"), address: z.string() }),
]),
});
<script lang="ts">
import { discriminated, FieldVariants } from 'sveltekit-discriminated-fields';
const shipping = $derived(discriminated(orderForm.fields.shipping, 'method'));
</script>
<FieldVariants fields={orderForm.fields.shipping} key="method">
{#snippet pickup(shipping)}
<input {...shipping} {...shipping.fields.store.as('text')} />
{/snippet}
{#snippet delivery(shipping)}
<input {...shipping} {...shipping.fields.address.as('text')} />
{/snippet}
</FieldVariants>
You can also have multiple discriminated unions in the same form, or even a discriminated union nested within another discriminated union. See the nested-form example for nested unions within objects, or nested-union-form example for a union inside a union.
By default, FieldVariants requires a snippet for every variant - a compile error appears if one is missing, helping you avoid omissions. When you intentionally want to handle only some variants, use partial={true}:
<FieldVariants fields={shapeForm.fields} key="kind" partial={true}>
{#snippet circle(shape)}
<input {...shape} {...shape.fields.radius.as('number')} />
{/snippet}
{#snippet rectangle(shape)}
<input {...shape} {...shape.fields.width.as('number')} />
{/snippet}
<!-- point snippet omitted - nothing shown when point selected -->
</FieldVariants>
FieldVariants provides true progressive enhancement:
This means forms work without JavaScript, but once JS loads, you get full Svelte features:
<FieldVariants fields={shapeForm.fields} key="kind">
{#snippet circle(shape)}
<!-- Svelte transitions work after hydration -->
<div {...shape} transition:slide>
<input {...shape.fields.radius.as('number')} />
</div>
{/snippet}
</FieldVariants>
If you want to handle visibility yourself, disable CSS generation:
<FieldVariants fields={shapeForm.fields} key="kind" css={false}>
<!-- snippets -->
</FieldVariants>
When using SvelteKit's remote function form() with discriminated union schemas, the generated fields object is a union of field objects. TypeScript only allows access to properties that exist on ALL variants - meaning variant-specific fields are inaccessible, and .as("radio", value) doesn't work.
The discriminated() function wraps your form fields to:
.type - the current discriminator value for TypeScript narrowing.fields - all variant fields accessible with proper typing.set() method for programmatic updates.as("radio", value) to accept only valid discriminator valuesThe following example demonstrates conditionally rendering variant-specific fields with type-safe narrowing, without using FieldVariants. This approach requires JavaScript (unlike FieldVariants which works without JS):
<script lang="ts">
import { shapeForm } from './data.remote';
import { discriminated } from 'sveltekit-discriminated-fields';
const shape = $derived(discriminated(shapeForm.fields, 'kind'));
</script>
<!-- Use .type for narrowing, .fields for field access -->
{#if shape.type === 'circle'}
<input {...shape.fields.radius.as('number')} /> <!-- TypeScript knows radius exists -->
{:else if shape.type === 'rectangle'}
<input {...shape.fields.width.as('number')} /> <!-- TypeScript knows width exists -->
<input {...shape.fields.height.as('number')} />
{/if}
See the programmatic-form example for usage of set() and other programmatic features.
FieldVariantsA component for rendering variant-specific form sections with CSS-only visibility.
Props:
| Prop | Type | Description |
|---|---|---|
fields |
RemoteFormFields |
Raw form fields from form.fields (not wrapped) |
key |
string |
The discriminator key (must match a field in the schema) |
partial |
boolean (optional) |
Allow missing snippets for some variants (default: false) |
css |
boolean (optional) |
Enable CSS visibility generation (default: true) |
Snippets:
fallback(props) - Rendered when no variant is selected. Spread props onto your element.{variantName}(variant) - One snippet per variant. Spread variant onto your container, access fields via variant.fields.discriminated(fields, key)Wraps discriminated union form fields for type-safe narrowing.
Parameters:
fields - Form fields from a discriminated union schemakey - The discriminator key (must exist as a field in all variants)Returns: A proxy object with:
type - The current discriminator value (for narrowing)fields - All form fields with proper variant typingset(data) - Type-safe setter that infers variant from discriminatorallIssues() - All validation issues for the discriminated fieldsDiscriminatedData<T>Type helper that extracts the underlying data type from wrapped fields:
const payment = discriminated(form.fields, "type");
type Payment = DiscriminatedData<typeof payment>;
// { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
VariantSnippetArg<T>Type for the argument passed to variant snippets:
// variant can be spread onto elements and has a .fields property
type VariantSnippetArg<T> = VariantProps & { readonly fields: T };
MIT