sveltekit-discriminated-fields 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,176 +1,259 @@
1
1
  # sveltekit-discriminated-fields
2
2
 
3
- Type-safe discriminated union support for SvelteKit form fields.
3
+ Type-safe discriminated union support for SvelteKit remote function form fields.
4
4
 
5
- ## The Problem
5
+ This library provides two complementary tools for working with discriminated unions in SvelteKit forms:
6
6
 
7
- When using SvelteKit's `form()` with discriminated union schemas (e.g., Zod's `z.discriminatedUnion`), 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 completely inaccessible.
7
+ - **`discriminatedFields()`** - A wrapper function that enables proper TypeScript type narrowing on form field objects
8
+ - **`UnionVariants`** - A component that renders variant-specific form sections with CSS-only visibility, supporting progressive enhancement (works without JavaScript)
8
9
 
9
- Given a typical SvelteKit setup:
10
+ The implementation prioritises:
11
+
12
+ - **Static type safety** - Catch errors at compile time with clear error messages
13
+ - **Progressive enhancement** - Forms work identically with or without JavaScript
14
+ - **Minimal boilerplate** - Simple API that stays out of your way
15
+ - **Flexibility** - Use as much or as little as you need
16
+
17
+ This library exposes existing SvelteKit form behaviour with improved typing for discriminated unions. Runtime overhead is minimal - just a thin proxy wrapper.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ npm install sveltekit-discriminated-fields
23
+ ```
24
+
25
+ ## UnionVariants Component
26
+
27
+ The `UnionVariants` component provides declarative variant rendering with **full progressive enhancement support**. It uses CSS-only visibility toggling via `:has()` selectors, so forms work identically with or without JavaScript enabled.
28
+
29
+ Given a SvelteKit remote function using Zod (this library also works with [Valibot](./test/src/routes/valibot-form) or other validation libraries):
10
30
 
11
31
  ```typescript
12
32
  // data.remote.ts
13
33
  import { z } from "zod";
14
- import { form } from "$app/server/remote";
34
+ import { form } from "$app/server";
15
35
 
16
36
  const shapeSchema = z.discriminatedUnion("kind", [
17
- z.object({ kind: z.literal("circle"), radius: z.number() }),
18
- z.object({
19
- kind: z.literal("rectangle"),
20
- width: z.number(),
21
- height: z.number(),
22
- }),
37
+ z.object({ kind: z.literal("circle"), radius: z.coerce.number() }),
38
+ z.object({ kind: z.literal("rectangle"), width: z.coerce.number(), height: z.coerce.number() }),
23
39
  z.object({ kind: z.literal("point") }),
24
40
  ]);
25
41
 
26
42
  export const shapeForm = form(shapeSchema, async (data) => {
27
- // handle form submission
43
+ // handle submission
28
44
  });
29
45
  ```
30
46
 
47
+ Use `UnionVariants` to render variant-specific fields:
48
+
31
49
  ```svelte
32
- <!-- +page.svelte (without this library) -->
33
50
  <script lang="ts">
34
- import { shapeForm } from './data.remote.ts';
35
-
36
- const form = shapeForm();
37
- const shape = form.fields;
51
+ import { shapeForm } from './data.remote';
52
+ import { UnionVariants } from 'sveltekit-discriminated-fields';
38
53
  </script>
39
54
 
40
- <select {...shape.kind.as('select')}>
41
- <option value="circle">Circle</option>
42
- <option value="rectangle">Rectangle</option>
43
- <option value="point">Point</option>
44
- </select>
45
-
46
- {#if shape.kind.value() === 'circle'}
47
- <!-- ERROR: Property 'radius' does not exist on type 'ShapeFields' -->
48
- <input {...shape.radius.as('number')} />
49
- {:else if shape.kind.value() === 'rectangle'}
50
- <!-- ERROR: Property 'width' does not exist on type 'ShapeFields' -->
51
- <input {...shape.width.as('number')} />
52
- {/if}
55
+ <form {...shapeForm}>
56
+ <select {...shapeForm.fields.kind.as('select')}>
57
+ <option value="">Select a shape...</option>
58
+ <option value="circle">Circle</option>
59
+ <option value="rectangle">Rectangle</option>
60
+ <option value="point">Point</option>
61
+ </select>
62
+
63
+ <UnionVariants fields={shapeForm.fields} key="kind">
64
+ {#snippet fallback()}
65
+ <p>Please select a shape type above.</p>
66
+ {/snippet}
67
+
68
+ {#snippet circle(s)}
69
+ <input {...s.radius.as('number')} placeholder="Radius" />
70
+ {/snippet}
71
+
72
+ {#snippet rectangle(s)}
73
+ <input {...s.width.as('number')} placeholder="Width" />
74
+ <input {...s.height.as('number')} placeholder="Height" />
75
+ {/snippet}
76
+
77
+ {#snippet point(_s)}
78
+ <p>Point has no additional fields.</p>
79
+ {/snippet}
80
+ </UnionVariants>
81
+
82
+ <button type="submit">Submit</button>
83
+ </form>
53
84
  ```
54
85
 
55
- The issue: `shape.kind.value() === 'circle'` doesn't narrow the type because method calls don't provide TypeScript control flow analysis. You're stuck - variant-specific fields are inaccessible, and there's no way to narrow to access them.
86
+ The optional `fallback` snippet is displayed when no variant is currently selected (i.e., when the discriminator field is empty).
56
87
 
57
- ## The Solution
88
+ Each snippet receives correctly narrowed fields for that variant - TypeScript knows `s.radius` exists in the `circle` snippet but not in `rectangle`. Only valid discriminator values are accepted.
58
89
 
59
- This library wraps your discriminated union fields to:
90
+ Snippets only receive fields **specific to that variant**. Fields common to all variants (same name and type) should be rendered outside `UnionVariants` to prevent accidental duplicate inputs. Fields shared by some but not all variants, or with differing types across variants, produce compile-time errors.
60
91
 
61
- 1. Make all variant fields accessible
62
- 2. Add a `${key}Value` property that enables TypeScript narrowing
92
+ ### Radio Buttons
93
+
94
+ For radio button discriminators, you must use the `discriminatedFields()` 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:
63
95
 
64
96
  ```svelte
65
- <!-- +page.svelte (with this library) -->
66
97
  <script lang="ts">
67
- import { shapeForm } from './data.remote.ts';
68
- import { discriminatedFields } from 'sveltekit-discriminated-fields'; // +
98
+ import { shapeForm } from './data.remote';
99
+ import { discriminatedFields, UnionVariants } from 'sveltekit-discriminated-fields';
69
100
 
70
- const form = shapeForm();
71
- const shape = discriminatedFields('kind', form.fields); // changed
101
+ const shape = $derived(discriminatedFields('kind', shapeForm.fields));
72
102
  </script>
73
103
 
74
- <select {...shape.kind.as('select')}>
75
- <option value="circle">Circle</option>
76
- <option value="rectangle">Rectangle</option>
77
- <option value="point">Point</option>
78
- </select>
104
+ <form {...shapeForm}>
105
+ <fieldset>
106
+ <label><input {...shape.kind.as("radio", "circle")} /> Circle</label>
107
+ <label><input {...shape.kind.as("radio", "rectangle")} /> Rectangle</label>
108
+ <label><input {...shape.kind.as("radio", "point")} /> Point</label>
109
+ </fieldset>
110
+
111
+ <UnionVariants fields={shape} key="kind">
112
+ <!-- snippets work the same way -->
113
+ </UnionVariants>
114
+ </form>
115
+ ```
79
116
 
80
- {#if shape.kindValue === 'circle'}<!-- changed -->
81
- <input {...shape.radius.as('number')} /><!-- now works! -->
82
- {:else if shape.kindValue === 'rectangle'}<!-- changed -->
83
- <input {...shape.width.as('number')} /><!-- now works! -->
84
- <input {...shape.height.as('number')} />
85
- <!-- ERROR: Property 'radius' does not exist - correctly rejected -->
86
- <input {...shape.radius.as('number')} />
87
- {/if}
117
+ See the [radio-form example](./test/src/routes/radio-form) for a complete working example.
118
+
119
+ ### Non-Sibling Layouts with `selector`
120
+
121
+ By default, `UnionVariants` expects the discriminator input (select or radios) to be a sibling element. For complex layouts where this isn't possible, use the `selector` prop:
122
+
123
+ ```svelte
124
+ <div class="header">
125
+ <select {...shape.kind.as('select')} id="shape-select">
126
+ <!-- options -->
127
+ </select>
128
+ </div>
129
+
130
+ <div class="body">
131
+ <!-- Not a sibling of the select! -->
132
+ <UnionVariants fields={shape} key="kind" selector="#shape-select">
133
+ <!-- snippets -->
134
+ </UnionVariants>
135
+ </div>
88
136
  ```
89
137
 
90
- The changes:
138
+ See the [selector-form example](./test/src/routes/selector-form) for select elements or [selector-radio-form example](./test/src/routes/selector-radio-form) for radio buttons.
91
139
 
92
- 1. Import `discriminatedFields` from this library
93
- 2. Wrap `form.fields` with `discriminatedFields('kind', ...)`
94
- 3. Use `shape.kindValue` instead of `shape.kind.value()` for narrowing
140
+ ### Nested and Multiple Unions
95
141
 
96
- ## Installation
142
+ The discriminated union doesn't need to be the top-level schema. It can be nested within a larger object:
97
143
 
98
- ```bash
99
- npm install sveltekit-discriminated-fields
144
+ ```typescript
145
+ const orderSchema = z.object({
146
+ orderId: z.string(),
147
+ shipping: z.discriminatedUnion("method", [
148
+ z.object({ method: z.literal("pickup"), store: z.string() }),
149
+ z.object({ method: z.literal("delivery"), address: z.string() }),
150
+ ]),
151
+ });
152
+ ```
153
+
154
+ ```svelte
155
+ <UnionVariants fields={orderForm.fields.shipping} key="method">
156
+ <!-- snippets for pickup and delivery -->
157
+ </UnionVariants>
100
158
  ```
101
159
 
102
- ## Usage
160
+ 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](./test/src/routes/nested-form) for nested unions within objects, or [nested-union-form example](./test/src/routes/nested-union-form) for a union inside a union.
161
+
162
+ ### Partial Variants
163
+
164
+ By default, `UnionVariants` 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}`:
103
165
 
104
166
  ```svelte
105
- <script lang="ts">
106
- import { paymentForm } from './data.remote.ts';
107
- import { discriminatedFields } from 'sveltekit-discriminated-fields';
167
+ <UnionVariants fields={shape} key="kind" partial={true}>
168
+ {#snippet circle(s)}
169
+ <input {...s.radius.as('number')} />
170
+ {/snippet}
108
171
 
109
- const form = paymentForm();
172
+ {#snippet rectangle(s)}
173
+ <input {...s.width.as('number')} />
174
+ {/snippet}
110
175
 
111
- // Wrap fields with $derived for reactivity
112
- // The first argument is your discriminator key (e.g., 'type', 'kind', 'channel')
113
- const payment = $derived(discriminatedFields('type', form.fields));
176
+ <!-- point snippet omitted - fallback shown when point selected -->
177
+ </UnionVariants>
178
+ ```
114
179
 
115
- function setCardDefaults() {
116
- // set() is type-safe - TypeScript enforces correct fields for each variant
117
- payment.set({ type: 'card', cardNumber: '', cvv: '' });
118
- }
119
- </script>
180
+ ## discriminatedFields Function
120
181
 
121
- <!-- typeValue is undefined until form is initialized -->
122
- {#if payment.typeValue === undefined}
123
- <p>Loading...</p>
124
- {:else}
125
- <select {...payment.type.as('select')}>
126
- <option value="card">Card</option>
127
- <option value="bank">Bank Transfer</option>
128
- </select>
182
+ 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.
183
+
184
+ The `discriminatedFields()` function wraps your form fields to:
185
+
186
+ 1. Make all variant fields accessible
187
+ 2. Add a `${key}Value` property that enables TypeScript narrowing
188
+ 3. Provide a type-safe `set()` method for programmatic updates
189
+ 4. Fix `.as("radio", value)` to accept only valid discriminator values
190
+
191
+ The following example demonstrates conditionally rendering variant-specific fields with type-safe narrowing, without using `UnionVariants`. This approach requires JavaScript (unlike `UnionVariants` which works without JS):
129
192
 
130
- <!-- Use typeValue (not type.value()) for type narrowing -->
131
- {#if payment.typeValue === 'card'}
132
- <input {...payment.cardNumber.as('string')} />
133
- <input {...payment.cvv.as('string')} />
134
- {:else if payment.typeValue === 'bank'}
135
- <input {...payment.accountNumber.as('string')} />
136
- <input {...payment.sortCode.as('string')} />
137
- {/if}
193
+ ```svelte
194
+ <script lang="ts">
195
+ import { shapeForm } from './data.remote';
196
+ import { discriminatedFields } from 'sveltekit-discriminated-fields';
197
+
198
+ const shape = $derived(discriminatedFields('kind', shapeForm.fields));
199
+ </script>
200
+
201
+ <!-- Use kindValue (not kind.value()) for type narrowing -->
202
+ {#if shape.kindValue === 'circle'}
203
+ <input {...shape.radius.as('number')} /> <!-- TypeScript knows radius exists -->
204
+ {:else if shape.kindValue === 'rectangle'}
205
+ <input {...shape.width.as('number')} /> <!-- TypeScript knows width exists -->
206
+ <input {...shape.height.as('number')} />
138
207
  {/if}
139
208
  ```
140
209
 
210
+ See the [programmatic-form example](./test/src/routes/programmatic-form) for usage of `set()` and other programmatic features.
211
+
141
212
  ## API
142
213
 
214
+ ### `UnionVariants`
215
+
216
+ A component for rendering variant-specific form sections with CSS-only visibility.
217
+
218
+ **Props:**
219
+
220
+ | Prop | Type | Description |
221
+ | ---------- | --------------------- | ----------------------------------------------------------- |
222
+ | `fields` | `DiscriminatedFields` | The wrapped form fields from `discriminatedFields()` |
223
+ | `key` | `string` | The discriminator key (must match a field in the schema) |
224
+ | `selector` | `string` (optional) | CSS selector for the discriminator input when not a sibling |
225
+ | `partial` | `boolean` (optional) | Allow missing snippets for some variants |
226
+
227
+ **Snippets:**
228
+
229
+ - `fallback` - Rendered when no variant is selected
230
+ - `{variantName}(fields)` - One snippet per variant, receives narrowed fields
231
+
143
232
  ### `discriminatedFields(key, fields)`
144
233
 
145
234
  Wraps discriminated union form fields for type-safe narrowing.
146
235
 
147
236
  **Parameters:**
148
237
 
149
- - `key` - The discriminator key (e.g., `'type'`, `'kind'`, `'channel'`)
238
+ - `key` - The discriminator key (must exist as a field in all variants)
150
239
  - `fields` - Form fields from a discriminated union schema
151
240
 
152
241
  **Returns:** A proxy object with:
153
242
 
154
243
  - All original fields passed through unchanged
155
244
  - `${key}Value` - The current discriminator value (for narrowing)
156
- - `set()` - Type-safe setter that infers variant from discriminator
245
+ - `set(data)` - Type-safe setter that infers variant from discriminator
157
246
 
158
247
  ### `DiscriminatedData<T>`
159
248
 
160
249
  Type helper that extracts the underlying data type from wrapped fields:
161
250
 
162
251
  ```typescript
163
- const payment = discriminatedFields('type', form.fields);
252
+ const payment = discriminatedFields("type", form.fields);
164
253
  type Payment = DiscriminatedData<typeof payment>;
165
- // { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; accountNumber: string; sortCode: string }
254
+ // { type: 'card'; cardNumber: string; cvv: string } | { type: 'bank'; ... }
166
255
  ```
167
256
 
168
- Useful when you need to reference the data type elsewhere, such as defining default values or passing data to other functions.
169
-
170
- ## Note on Runtime Behavior
171
-
172
- Type narrowing is compile-time only. At runtime, `'width' in shape` will return `true` even when `kindValue === 'circle'`, because all variant fields exist on the underlying object.
173
-
174
257
  ## License
175
258
 
176
259
  MIT
@@ -0,0 +1,361 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type { InferDiscriminator } from "./infer-discriminator.js";
4
+
5
+ // Extract discriminator values from a union of field objects
6
+ type DiscriminatorValues<K extends string, T> =
7
+ T extends Record<K, { value(): infer V }>
8
+ ? V extends string
9
+ ? V
10
+ : never
11
+ : never;
12
+
13
+ // Narrow T to the variant where discriminator equals V
14
+ type NarrowToVariant<K extends string, T, V> =
15
+ T extends Record<K, { value(): V }> ? T : never;
16
+
17
+ // Reserved prop names that can't be variant names
18
+ type ReservedProps = "fields" | "key" | "fallback" | "partial";
19
+
20
+ // =============================================================================
21
+ // Shared field name detection
22
+ // Fields can be:
23
+ // - Unique to one variant: OK
24
+ // - Common to ALL variants: OK (accessible at top level, outside UnionVariants)
25
+ // - Shared by SOME but not ALL: ERROR (would cause duplicate form submissions)
26
+ // =============================================================================
27
+
28
+ // Keys that look like form fields (have a value() method) - excludes methods like set/allIssues
29
+ type FieldKeys<T> = {
30
+ [P in keyof T]: T[P] extends { value(): unknown } ? P : never;
31
+ }[keyof T];
32
+
33
+ // Get field keys for a specific variant (excluding discriminator)
34
+ type VariantFieldKeys<K extends string, T, V extends string> = Exclude<
35
+ FieldKeys<NarrowToVariant<K, T, V>>,
36
+ K
37
+ >;
38
+
39
+ // Get union of field keys from all variants EXCEPT V (distributes over the Exclude result)
40
+ type OtherVariantsFieldKeys<
41
+ K extends string,
42
+ T,
43
+ V extends string,
44
+ OtherV extends string = Exclude<DiscriminatorValues<K, T>, V>,
45
+ > = OtherV extends OtherV ? VariantFieldKeys<K, T, OtherV> : never;
46
+
47
+ // Keys that variant V shares with at least one other variant
48
+ type SharedFieldKeys<
49
+ K extends string,
50
+ T,
51
+ V extends string,
52
+ > = VariantFieldKeys<K, T, V> & OtherVariantsFieldKeys<K, T, V>;
53
+
54
+ // Union of all shared keys across all variants (distributes over V)
55
+ type AllSharedFieldKeys<
56
+ K extends string,
57
+ T,
58
+ V extends string = DiscriminatorValues<K, T>,
59
+ > = V extends V ? SharedFieldKeys<K, T, V> : never;
60
+
61
+ // Check if field P exists in variant V
62
+ type HasField<T, P> = P extends keyof T
63
+ ? T[P] extends { value(): unknown }
64
+ ? true
65
+ : false
66
+ : false;
67
+
68
+ // Check if field P exists in ALL variants (distributes, so result is union of true/false)
69
+ type FieldExistsInAll<
70
+ K extends string,
71
+ T,
72
+ P extends string,
73
+ V extends string = DiscriminatorValues<K, T>,
74
+ > = V extends V ? HasField<NarrowToVariant<K, T, V>, P> : never;
75
+
76
+ // A field is common if it exists in all variants (no false in the result)
77
+ type IsCommonField<K extends string, T, P extends string> = false extends FieldExistsInAll<K, T, P>
78
+ ? false
79
+ : true;
80
+
81
+ // =============================================================================
82
+ // Type uniformity check - fields shared across variants must have the same type
83
+ // =============================================================================
84
+
85
+ // Get the value type of field P in variant V
86
+ type FieldValueType<K extends string, T, V extends string, P extends string> = NarrowToVariant<
87
+ K,
88
+ T,
89
+ V
90
+ >[P & keyof NarrowToVariant<K, T, V>] extends { value(): infer R }
91
+ ? R
92
+ : never;
93
+
94
+ // Union of all value types for field P across all variants
95
+ type AllFieldValueTypes<
96
+ K extends string,
97
+ T,
98
+ P extends string,
99
+ V extends string = DiscriminatorValues<K, T>,
100
+ > = V extends V ? FieldValueType<K, T, V, P> : never;
101
+
102
+ // Check if the union of all types equals each individual type (i.e., all types are the same)
103
+ // If types differ: (string | number) extends string = false
104
+ // If types same: string extends string = true
105
+ type FieldTypeMatchesInVariant<
106
+ K extends string,
107
+ T,
108
+ P extends string,
109
+ V extends string,
110
+ Union = AllFieldValueTypes<K, T, P>,
111
+ > = [Union] extends [FieldValueType<K, T, V, P>] ? true : false;
112
+
113
+ // Check if field P has the same type in ALL variants
114
+ type FieldHasSameTypeInAll<
115
+ K extends string,
116
+ T,
117
+ P extends string,
118
+ V extends string = DiscriminatorValues<K, T>,
119
+ > = V extends V ? FieldTypeMatchesInVariant<K, T, P, V> : never;
120
+
121
+ // A field has uniform type if no variant returns false
122
+ type IsUniformTypeField<K extends string, T, P extends string> = false extends FieldHasSameTypeInAll<
123
+ K,
124
+ T,
125
+ P
126
+ >
127
+ ? false
128
+ : true;
129
+
130
+ // =============================================================================
131
+ // Field classification
132
+ // =============================================================================
133
+
134
+ // Fields shared by SOME but not ALL variants (problematic - would cause duplicate submissions)
135
+ type PartiallySharedFieldKeys<K extends string, T> = {
136
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? never : P;
137
+ }[AllSharedFieldKeys<K, T>];
138
+
139
+ // Fields in ALL variants but with DIFFERENT types (problematic - type mismatch)
140
+ type MixedTypeFieldKeys<K extends string, T> = {
141
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true
142
+ ? IsUniformTypeField<K, T, P & string> extends true
143
+ ? never
144
+ : P
145
+ : never;
146
+ }[AllSharedFieldKeys<K, T>];
147
+
148
+ // Fields common to ALL variants WITH the same type (safe to use outside UnionVariants)
149
+ type CommonFieldKeys<K extends string, T> = {
150
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true
151
+ ? IsUniformTypeField<K, T, P & string> extends true
152
+ ? P
153
+ : never
154
+ : never;
155
+ }[AllSharedFieldKeys<K, T>];
156
+
157
+ // =============================================================================
158
+ // Validation - produces error messages for invalid field configurations
159
+ // =============================================================================
160
+
161
+ // Validates that T has no partially shared field keys
162
+ type ValidateNoPartiallySharedFields<K extends string, T> = [
163
+ PartiallySharedFieldKeys<K, T>,
164
+ ] extends [never]
165
+ ? T
166
+ : `Error: Field '${PartiallySharedFieldKeys<K, T> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
167
+
168
+ // Validates that T has no mixed-type field keys
169
+ type ValidateNoMixedTypeFields<K extends string, T> = [MixedTypeFieldKeys<K, T>] extends [never]
170
+ ? T
171
+ : `Error: Field '${MixedTypeFieldKeys<K, T> & string}' exists in all variants but with different types. Shared fields must have the same type across all variants.`;
172
+
173
+ // Combined validation
174
+ type ValidateFields<K extends string, T> = [PartiallySharedFieldKeys<K, T>] extends [never]
175
+ ? [MixedTypeFieldKeys<K, T>] extends [never]
176
+ ? T
177
+ : ValidateNoMixedTypeFields<K, T>
178
+ : ValidateNoPartiallySharedFields<K, T>;
179
+
180
+ // Variant type with common fields removed (for snippet parameters)
181
+ // This prevents accidentally rendering common fields twice
182
+ type VariantOnlyFields<K extends string, T, V extends string> = Omit<
183
+ NarrowToVariant<K, T, V>,
184
+ CommonFieldKeys<K, T>
185
+ >;
186
+
187
+ // Snippet props mapped from variant values - optionality controlled by Partial flag
188
+ // Note: snippets receive variant-only fields (common fields are excluded to prevent duplicates)
189
+ type VariantSnippets<
190
+ K extends string,
191
+ T,
192
+ V extends string,
193
+ IsPartial extends boolean,
194
+ > = IsPartial extends true
195
+ ? { [P in Exclude<V, ReservedProps>]?: Snippet<[VariantOnlyFields<K, T, P>]> }
196
+ : { [P in Exclude<V, ReservedProps>]: Snippet<[VariantOnlyFields<K, T, P>]> };
197
+
198
+ // Full props type with explicit key
199
+ export type UnionVariantsPropsWithKey<
200
+ K extends string,
201
+ T extends Record<K, { value(): unknown }>,
202
+ V extends string = DiscriminatorValues<K, T>,
203
+ IsPartial extends boolean = false,
204
+ > = {
205
+ fields: ValidateFields<K, T>;
206
+ key: K;
207
+ /** Optional snippet shown when no variant is selected */
208
+ fallback?: Snippet;
209
+ /** When true, variant snippets are optional (default: false) */
210
+ partial?: IsPartial;
211
+ /**
212
+ * CSS selector for the discriminator input element (select or radio container).
213
+ * When provided, uses this selector instead of the default name-based lookup.
214
+ * Example: "#method-select" for a select, or "#radio-group" for radios
215
+ */
216
+ selector?: string;
217
+ } & VariantSnippets<K, T, V, IsPartial>;
218
+
219
+ // Props type with auto-inferred key (key is optional)
220
+ export type UnionVariantsPropsInferred<
221
+ T extends Record<string, { value(): unknown }>,
222
+ K extends string = InferDiscriminator<T> extends infer I ? (I extends string ? I : never) : never,
223
+ V extends string = DiscriminatorValues<K, T>,
224
+ IsPartial extends boolean = false,
225
+ > = InferDiscriminator<T> extends string
226
+ ? {
227
+ fields: ValidateFields<K, T>;
228
+ key?: K;
229
+ fallback?: Snippet;
230
+ partial?: IsPartial;
231
+ selector?: string;
232
+ } & VariantSnippets<K, T, V, IsPartial>
233
+ : {
234
+ fields: InferDiscriminator<T>; // This will be the error message
235
+ key: never;
236
+ };
237
+
238
+ // Combined props type - key can be explicit or inferred
239
+ export type UnionVariantsProps<
240
+ K extends string,
241
+ T extends Record<K, { value(): unknown }>,
242
+ V extends string = DiscriminatorValues<K, T>,
243
+ IsPartial extends boolean = false,
244
+ > = UnionVariantsPropsWithKey<K, T, V, IsPartial>;
245
+ </script>
246
+
247
+ <script
248
+ lang="ts"
249
+ generics="K extends string, T extends Record<K, { value(): unknown }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false"
250
+ >
251
+ type Props = UnionVariantsProps<K, T, V, IsPartial>;
252
+
253
+ let { fields, key: keyProp, fallback, partial, selector, ...snippets }: Props = $props();
254
+
255
+ // Auto-detect discriminator key if not provided
256
+ function findDiscriminatorKey(f: Record<string, { value(): unknown }>): string {
257
+ const fieldKeys = Object.keys(f).filter((k) => {
258
+ const field = f[k];
259
+ return field && typeof field === "object" && "value" in field && typeof field.value === "function";
260
+ });
261
+
262
+ const candidates = fieldKeys.filter((k) => {
263
+ const field = f[k];
264
+ const value = field.value();
265
+ return typeof value === "string" || value === undefined;
266
+ });
267
+
268
+ if (candidates.length === 0) {
269
+ throw new Error("No valid discriminator key found in fields");
270
+ }
271
+ return candidates[0];
272
+ }
273
+
274
+ // Use provided key or auto-detect
275
+ const key = $derived(keyProp ?? findDiscriminatorKey(fields as Record<string, { value(): unknown }>)) as K;
276
+
277
+ // Get all variant values from the snippet names (excluding fallback)
278
+ const variantValues = Object.keys(snippets) as V[];
279
+
280
+ // Get the actual field name from the discriminator field (handles nested paths like "shipping.method")
281
+ const fieldName = $derived.by(() => {
282
+ const discriminatorField = (fields as Record<K, { as(type: "select"): { name: string } }>)[key];
283
+ return discriminatorField?.as("select")?.name ?? key;
284
+ });
285
+
286
+ // Generate CSS for showing/hiding variants based on select or radio value
287
+ const css = $derived.by(() => {
288
+ const attr = `data-union-${key}`;
289
+ const name = fieldName;
290
+
291
+ if (selector) {
292
+ // Input-based: use :has() with the provided selector to find any ancestor
293
+ // Supports: select elements, radio inputs directly, or radio containers
294
+ const selOpt = (v: string) => `*:has(${selector} option[value="${v}"]:checked)`;
295
+ const radDirect = (v: string) => `*:has(${selector}[value="${v}"]:checked)`;
296
+ const radContainer = (v: string) => `*:has(${selector} input[value="${v}"]:checked)`;
297
+
298
+ // Hide all variant sections by default
299
+ const hideAll = `*:has(${selector}) [${attr}]:not([${attr}="fallback"]) { display: none; }\n`;
300
+
301
+ // Show the variant section that matches the selected value
302
+ const showVariants = variantValues
303
+ .map((v) => `${selOpt(v)} [${attr}="${v}"],
304
+ ${radDirect(v)} [${attr}="${v}"],
305
+ ${radContainer(v)} [${attr}="${v}"] { display: contents; }`)
306
+ .join("\n");
307
+
308
+ // Hide fallback when any variant is selected
309
+ const hideFallback =
310
+ fallback && variantValues.length
311
+ ? `*:has(${selector}:checked) [${attr}="fallback"],
312
+ *:has(${selector} input:checked) [${attr}="fallback"],
313
+ ${variantValues.map((v) => `${selOpt(v)} [${attr}="fallback"]`).join(",\n")} { display: none; }\n`
314
+ : "";
315
+
316
+ return hideAll + showVariants + hideFallback;
317
+ }
318
+
319
+ // Sibling-based (default): look for sibling elements containing the discriminator
320
+ const sel = (s: string) => `*:has(select[name="${name}"]${s}) ~`;
321
+ const rad = (s: string) => `*:has(input[name="${name}"]${s}) ~`;
322
+
323
+ // Hide all variant sections by default (but not fallback)
324
+ const hideAll = `${sel("")} [${attr}]:not([${attr}="fallback"]),
325
+ ${rad("")} [${attr}]:not([${attr}="fallback"]) { display: none; }\n`;
326
+
327
+ // Show the variant section that matches the selected value
328
+ const showVariants = variantValues
329
+ .map((v) => `${sel(` option[value="${v}"]:checked`)} [${attr}="${v}"],
330
+ ${rad(`[value="${v}"]:checked`)} [${attr}="${v}"] { display: contents; }`)
331
+ .join("\n");
332
+
333
+ // Hide fallback when any variant is selected
334
+ const hideFallback =
335
+ fallback && variantValues.length
336
+ ? `${rad(":checked")} [${attr}="fallback"],
337
+ ${variantValues.map((v) => `${sel(` option[value="${v}"]:checked`)} [${attr}="fallback"]`).join(",\n")} { display: none; }\n`
338
+ : "";
339
+
340
+ return hideAll + showVariants + hideFallback;
341
+ });
342
+ </script>
343
+
344
+ <svelte:head>
345
+ {@html `<style>${css}</style>`}
346
+ </svelte:head>
347
+
348
+ {#if fallback}
349
+ {@const attrs = { [`data-union-${key}`]: "fallback" }}
350
+ <div {...attrs}>
351
+ {@render fallback()}
352
+ </div>
353
+ {/if}
354
+
355
+ {#each variantValues as v (v)}
356
+ {@const snippet = (snippets as unknown as Record<V, Snippet<[T]>>)[v]}
357
+ {@const attrs = { [`data-union-${key}`]: v }}
358
+ <div {...attrs}>
359
+ {@render snippet(fields as T)}
360
+ </div>
361
+ {/each}
@@ -0,0 +1,115 @@
1
+ import type { Snippet } from "svelte";
2
+ import type { InferDiscriminator } from "./infer-discriminator.js";
3
+ type DiscriminatorValues<K extends string, T> = T extends Record<K, {
4
+ value(): infer V;
5
+ }> ? V extends string ? V : never : never;
6
+ type NarrowToVariant<K extends string, T, V> = T extends Record<K, {
7
+ value(): V;
8
+ }> ? T : never;
9
+ type ReservedProps = "fields" | "key" | "fallback" | "partial";
10
+ type FieldKeys<T> = {
11
+ [P in keyof T]: T[P] extends {
12
+ value(): unknown;
13
+ } ? P : never;
14
+ }[keyof T];
15
+ type VariantFieldKeys<K extends string, T, V extends string> = Exclude<FieldKeys<NarrowToVariant<K, T, V>>, K>;
16
+ type OtherVariantsFieldKeys<K extends string, T, V extends string, OtherV extends string = Exclude<DiscriminatorValues<K, T>, V>> = OtherV extends OtherV ? VariantFieldKeys<K, T, OtherV> : never;
17
+ type SharedFieldKeys<K extends string, T, V extends string> = VariantFieldKeys<K, T, V> & OtherVariantsFieldKeys<K, T, V>;
18
+ type AllSharedFieldKeys<K extends string, T, V extends string = DiscriminatorValues<K, T>> = V extends V ? SharedFieldKeys<K, T, V> : never;
19
+ type HasField<T, P> = P extends keyof T ? T[P] extends {
20
+ value(): unknown;
21
+ } ? true : false : false;
22
+ type FieldExistsInAll<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? HasField<NarrowToVariant<K, T, V>, P> : never;
23
+ type IsCommonField<K extends string, T, P extends string> = false extends FieldExistsInAll<K, T, P> ? false : true;
24
+ type FieldValueType<K extends string, T, V extends string, P extends string> = NarrowToVariant<K, T, V>[P & keyof NarrowToVariant<K, T, V>] extends {
25
+ value(): infer R;
26
+ } ? R : never;
27
+ type AllFieldValueTypes<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? FieldValueType<K, T, V, P> : never;
28
+ type FieldTypeMatchesInVariant<K extends string, T, P extends string, V extends string, Union = AllFieldValueTypes<K, T, P>> = [Union] extends [FieldValueType<K, T, V, P>] ? true : false;
29
+ type FieldHasSameTypeInAll<K extends string, T, P extends string, V extends string = DiscriminatorValues<K, T>> = V extends V ? FieldTypeMatchesInVariant<K, T, P, V> : never;
30
+ type IsUniformTypeField<K extends string, T, P extends string> = false extends FieldHasSameTypeInAll<K, T, P> ? false : true;
31
+ type PartiallySharedFieldKeys<K extends string, T> = {
32
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? never : P;
33
+ }[AllSharedFieldKeys<K, T>];
34
+ type MixedTypeFieldKeys<K extends string, T> = {
35
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? IsUniformTypeField<K, T, P & string> extends true ? never : P : never;
36
+ }[AllSharedFieldKeys<K, T>];
37
+ type CommonFieldKeys<K extends string, T> = {
38
+ [P in AllSharedFieldKeys<K, T>]: IsCommonField<K, T, P & string> extends true ? IsUniformTypeField<K, T, P & string> extends true ? P : never : never;
39
+ }[AllSharedFieldKeys<K, T>];
40
+ type ValidateNoPartiallySharedFields<K extends string, T> = [
41
+ PartiallySharedFieldKeys<K, T>
42
+ ] extends [never] ? T : `Error: Field '${PartiallySharedFieldKeys<K, T> & string}' exists in some variants but not all. Fields must be either unique to one variant or common to all variants.`;
43
+ type ValidateNoMixedTypeFields<K extends string, T> = [MixedTypeFieldKeys<K, T>] extends [never] ? T : `Error: Field '${MixedTypeFieldKeys<K, T> & string}' exists in all variants but with different types. Shared fields must have the same type across all variants.`;
44
+ type ValidateFields<K extends string, T> = [PartiallySharedFieldKeys<K, T>] extends [never] ? [MixedTypeFieldKeys<K, T>] extends [never] ? T : ValidateNoMixedTypeFields<K, T> : ValidateNoPartiallySharedFields<K, T>;
45
+ type VariantOnlyFields<K extends string, T, V extends string> = Omit<NarrowToVariant<K, T, V>, CommonFieldKeys<K, T>>;
46
+ type VariantSnippets<K extends string, T, V extends string, IsPartial extends boolean> = IsPartial extends true ? {
47
+ [P in Exclude<V, ReservedProps>]?: Snippet<[VariantOnlyFields<K, T, P>]>;
48
+ } : {
49
+ [P in Exclude<V, ReservedProps>]: Snippet<[VariantOnlyFields<K, T, P>]>;
50
+ };
51
+ export type UnionVariantsPropsWithKey<K extends string, T extends Record<K, {
52
+ value(): unknown;
53
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = {
54
+ fields: ValidateFields<K, T>;
55
+ key: K;
56
+ /** Optional snippet shown when no variant is selected */
57
+ fallback?: Snippet;
58
+ /** When true, variant snippets are optional (default: false) */
59
+ partial?: IsPartial;
60
+ /**
61
+ * CSS selector for the discriminator input element (select or radio container).
62
+ * When provided, uses this selector instead of the default name-based lookup.
63
+ * Example: "#method-select" for a select, or "#radio-group" for radios
64
+ */
65
+ selector?: string;
66
+ } & VariantSnippets<K, T, V, IsPartial>;
67
+ export type UnionVariantsPropsInferred<T extends Record<string, {
68
+ value(): unknown;
69
+ }>, K extends string = InferDiscriminator<T> extends infer I ? (I extends string ? I : never) : never, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = InferDiscriminator<T> extends string ? {
70
+ fields: ValidateFields<K, T>;
71
+ key?: K;
72
+ fallback?: Snippet;
73
+ partial?: IsPartial;
74
+ selector?: string;
75
+ } & VariantSnippets<K, T, V, IsPartial> : {
76
+ fields: InferDiscriminator<T>;
77
+ key: never;
78
+ };
79
+ export type UnionVariantsProps<K extends string, T extends Record<K, {
80
+ value(): unknown;
81
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = UnionVariantsPropsWithKey<K, T, V, IsPartial>;
82
+ declare function $$render<K extends string, T extends Record<K, {
83
+ value(): unknown;
84
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false>(): {
85
+ props: UnionVariantsProps<K, T, V, IsPartial>;
86
+ exports: {};
87
+ bindings: "";
88
+ slots: {};
89
+ events: {};
90
+ };
91
+ declare class __sveltets_Render<K extends string, T extends Record<K, {
92
+ value(): unknown;
93
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> {
94
+ props(): ReturnType<typeof $$render<K, T, V, IsPartial>>['props'];
95
+ events(): ReturnType<typeof $$render<K, T, V, IsPartial>>['events'];
96
+ slots(): ReturnType<typeof $$render<K, T, V, IsPartial>>['slots'];
97
+ bindings(): "";
98
+ exports(): {};
99
+ }
100
+ interface $$IsomorphicComponent {
101
+ new <K extends string, T extends Record<K, {
102
+ value(): unknown;
103
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']>, ReturnType<__sveltets_Render<K, T, V, IsPartial>['events']>, ReturnType<__sveltets_Render<K, T, V, IsPartial>['slots']>> & {
104
+ $$bindings?: ReturnType<__sveltets_Render<K, T, V, IsPartial>['bindings']>;
105
+ } & ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
106
+ <K extends string, T extends Record<K, {
107
+ value(): unknown;
108
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false>(internal: unknown, props: ReturnType<__sveltets_Render<K, T, V, IsPartial>['props']> & {}): ReturnType<__sveltets_Render<K, T, V, IsPartial>['exports']>;
109
+ z_$$bindings?: ReturnType<__sveltets_Render<any, any, any, any>['bindings']>;
110
+ }
111
+ declare const UnionVariants: $$IsomorphicComponent;
112
+ type UnionVariants<K extends string, T extends Record<K, {
113
+ value(): unknown;
114
+ }>, V extends string = DiscriminatorValues<K, T>, IsPartial extends boolean = false> = InstanceType<typeof UnionVariants<K, T, V, IsPartial>>;
115
+ export default UnionVariants;
@@ -0,0 +1,70 @@
1
+ import type { RemoteFormField, RemoteFormFieldType } from '@sveltejs/kit';
2
+ export type DiscriminatedData<T> = T extends {
3
+ set: (v: infer D) => unknown;
4
+ } ? D : never;
5
+ type ExtractFieldValue<F> = F extends RemoteFormField<infer V> ? V : never;
6
+ type RadioProps = {
7
+ name: string;
8
+ type: 'radio';
9
+ value?: string;
10
+ 'aria-invalid': boolean | 'false' | 'true' | undefined;
11
+ get checked(): boolean;
12
+ set checked(value: boolean);
13
+ };
14
+ type NonRadioAsArgs<Type extends string, Value> = Type extends 'checkbox' ? Value extends string[] ? [type: Type, value: Value[number] | (string & {})] : [type: Type] : Type extends 'submit' | 'hidden' ? [type: Type, value: Value | (string & {})] : [type: Type];
15
+ type StrictDiscriminatorField<Value extends string, BaseField extends RemoteFormField<Value>> = Omit<BaseField, 'as'> & {
16
+ as(type: 'radio', value: Value): RadioProps;
17
+ as<T extends Exclude<RemoteFormFieldType<Value>, 'radio'>>(...args: NonRadioAsArgs<T, Value>): ReturnType<BaseField['as']>;
18
+ };
19
+ type UnifyField<F, V extends string = ExtractFieldValue<F> & string> = StrictDiscriminatorField<V, RemoteFormField<V>>;
20
+ type Setter<K extends string, T, D = DiscriminatedData<T>> = {
21
+ set: <V extends D[K & keyof D]>(data: Extract<D, {
22
+ [P in K]: V;
23
+ }>) => void;
24
+ };
25
+ type Keys<T> = T extends unknown ? keyof T : never;
26
+ type KeyValue<K extends string, V> = {
27
+ [P in `${K}Value`]: V;
28
+ };
29
+ type Fields<T> = {
30
+ [P in Keys<T>]: T extends Record<P, infer V> ? V : never;
31
+ };
32
+ type Variant<K extends string, T, V> = KeyValue<K, V> & Fields<T>;
33
+ type Narrowed<K extends string, T> = T extends {
34
+ [P in K]: {
35
+ value(): infer V;
36
+ };
37
+ } ? Variant<K, T, V> : never;
38
+ type Union<K extends string, T> = Narrowed<K, T> | Variant<K, T, undefined>;
39
+ type FieldUnion<K extends string, T> = T extends Record<K, infer F> ? F : never;
40
+ type UnifiedDiscriminator<K extends string, T, F = FieldUnion<K, T>> = {
41
+ readonly [P in K]: [F] extends [never] ? never : UnifyField<F>;
42
+ };
43
+ type DiscriminatedFields<K extends string, T> = Setter<K, T> & Union<K, T> & UnifiedDiscriminator<K, T>;
44
+ /**
45
+ * Wraps discriminated union form fields for type-safe access.
46
+ * - All original fields pass through unchanged (type, issues, allIssues, etc.)
47
+ * - `set` is overridden with type-safe version
48
+ * - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
49
+ * - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
50
+ *
51
+ * @example
52
+ * ```svelte
53
+ * <script>
54
+ * const priority = $derived(discriminatedFields("level", priorityForm.fields));
55
+ * </script>
56
+ *
57
+ * <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
58
+ * ```
59
+ *
60
+ * @param key - Discriminator key (e.g. 'type')
61
+ * @param fields - Form fields from a discriminated union schema
62
+ * @returns Passthrough object with type-safe set(), ${key}Value, and .as("radio", value)
63
+ */
64
+ export declare function discriminatedFields<K extends string, T extends {
65
+ set: (v: never) => unknown;
66
+ } & Record<K, {
67
+ value(): unknown;
68
+ as(type: 'radio', value: string): object;
69
+ }>>(key: K, fields: T): DiscriminatedFields<K, T>;
70
+ export {};
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Wraps discriminated union form fields for type-safe access.
3
+ * - All original fields pass through unchanged (type, issues, allIssues, etc.)
4
+ * - `set` is overridden with type-safe version
5
+ * - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
6
+ * - Discriminator field `.as("radio", value)` is type-safe (only valid values allowed)
7
+ *
8
+ * @example
9
+ * ```svelte
10
+ * <script>
11
+ * const priority = $derived(discriminatedFields("level", priorityForm.fields));
12
+ * </script>
13
+ *
14
+ * <input {...priority.level.as("radio", "high")} /> <!-- type-safe: only valid values allowed -->
15
+ * ```
16
+ *
17
+ * @param key - Discriminator key (e.g. 'type')
18
+ * @param fields - Form fields from a discriminated union schema
19
+ * @returns Passthrough object with type-safe set(), ${key}Value, and .as("radio", value)
20
+ */
21
+ export function discriminatedFields(key, fields) {
22
+ const proxy = new Proxy(fields, {
23
+ get(target, prop) {
24
+ if (prop === `${key}Value`)
25
+ return target[key].value();
26
+ if (prop === 'set')
27
+ return (data) => target.set(data);
28
+ return Reflect.get(target, prop);
29
+ },
30
+ has(target, prop) {
31
+ return prop === `${key}Value` || prop in target;
32
+ }
33
+ });
34
+ return proxy;
35
+ }
package/dist/index.d.ts CHANGED
@@ -1,39 +1,4 @@
1
- export type DiscriminatedData<T> = T extends {
2
- set: (v: infer D) => unknown;
3
- } ? D : never;
4
- type Setter<K extends string, T, D = DiscriminatedData<T>> = {
5
- set: <V extends D[K & keyof D]>(data: Extract<D, {
6
- [P in K]: V;
7
- }>) => void;
8
- };
9
- type Keys<T> = T extends unknown ? keyof T : never;
10
- type KeyValue<K extends string, V> = {
11
- [P in `${K}Value`]: V;
12
- };
13
- type Fields<T> = {
14
- [P in Keys<T>]: T extends Record<P, infer V> ? V : never;
15
- };
16
- type Variant<K extends string, T, V> = KeyValue<K, V> & Fields<T>;
17
- type Narrowed<K extends string, T> = T extends {
18
- [P in K]: {
19
- value(): infer V;
20
- };
21
- } ? Variant<K, T, V> : never;
22
- type Union<K extends string, T> = Narrowed<K, T> | Variant<K, T, undefined>;
23
- type DiscriminatedFields<K extends string, T> = Setter<K, T> & Union<K, T>;
24
- /**
25
- * Wraps discriminated union form fields, adding ${key}Value for type narrowing.
26
- * - All original fields pass through unchanged (type, issues, allIssues, etc.)
27
- * - `set` is overridden with type-safe version
28
- * - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
29
- *
30
- * @param key - Discriminator key (e.g. 'type')
31
- * @param fields - Form fields from a discriminated union schema
32
- * @returns Passthrough object with type-safe set() and ${key}Value for narrowing
33
- */
34
- export declare function discriminatedFields<K extends string, T extends {
35
- set: (v: never) => unknown;
36
- } & Record<K, {
37
- value(): unknown;
38
- }>>(key: K, fields: T): DiscriminatedFields<K, T>;
39
- export {};
1
+ export { discriminatedFields, type DiscriminatedData } from './discriminated-fields.js';
2
+ export type { InferDiscriminator } from './infer-discriminator.js';
3
+ export { default as UnionVariants } from './UnionVariants.svelte';
4
+ export type { UnionVariantsProps } from './UnionVariants.svelte';
package/dist/index.js CHANGED
@@ -1,25 +1,4 @@
1
- /**
2
- * Wraps discriminated union form fields, adding ${key}Value for type narrowing.
3
- * - All original fields pass through unchanged (type, issues, allIssues, etc.)
4
- * - `set` is overridden with type-safe version
5
- * - `${key}Value` is added for discriminator value (e.g., `reward.typeValue`)
6
- *
7
- * @param key - Discriminator key (e.g. 'type')
8
- * @param fields - Form fields from a discriminated union schema
9
- * @returns Passthrough object with type-safe set() and ${key}Value for narrowing
10
- */
11
- export function discriminatedFields(key, fields) {
12
- const proxy = new Proxy(fields, {
13
- get(target, prop) {
14
- if (prop === `${key}Value`)
15
- return target[key].value();
16
- if (prop === 'set')
17
- return (data) => target.set(data);
18
- return Reflect.get(target, prop);
19
- },
20
- has(target, prop) {
21
- return prop === `${key}Value` || prop in target;
22
- }
23
- });
24
- return proxy;
25
- }
1
+ // Re-export discriminatedFields function and types
2
+ export { discriminatedFields } from './discriminated-fields.js';
3
+ // Re-export UnionVariants component and its types
4
+ export { default as UnionVariants } from './UnionVariants.svelte';
@@ -0,0 +1,11 @@
1
+ type AllKeys<T> = T extends T ? keyof T : never;
2
+ type HasLiteralValue<T, K> = K extends keyof T ? T[K] extends {
3
+ value(): infer V;
4
+ } ? V extends string ? string extends V ? false : true : false : false : false;
5
+ type IsDiscriminatorInAll<T, K> = T extends T ? HasLiteralValue<T, K> : never;
6
+ type IsDiscriminator<T, K> = [IsDiscriminatorInAll<T, K>] extends [true] ? true : false;
7
+ type DiscriminatorKeys<T, K = AllKeys<T>> = K extends K ? IsDiscriminator<T, K> extends true ? K : never : never;
8
+ type IsUnion<T, U = T> = T extends T ? ([U] extends [T] ? false : true) : never;
9
+ export type InferDiscriminator<T> = DiscriminatorKeys<T> extends infer Keys ? [Keys] extends [never] ? "Error: No valid discriminator found. A discriminator must exist in all variants with distinct string literal values." : IsUnion<Keys> extends true ? `Error: Multiple discriminator candidates found: ${Keys & string}. Please specify the key explicitly.` : Keys : never;
10
+ export type IsValidInference<T> = InferDiscriminator<T> extends `Error: ${string}` ? false : true;
11
+ export type { DiscriminatorKeys };
@@ -0,0 +1,5 @@
1
+ // Auto-detect discriminator key from a union of form field objects
2
+ //
3
+ // This allows discriminatedFields() and UnionVariants to work without
4
+ // explicitly specifying the discriminator key, when it can be unambiguously inferred.
5
+ export {};
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "sveltekit-discriminated-fields",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Type-safe discriminated union support for SvelteKit form fields",
5
5
  "type": "module",
6
+ "svelte": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
6
8
  "exports": {
7
9
  ".": {
8
10
  "types": "./dist/index.d.ts",
11
+ "svelte": "./dist/index.js",
9
12
  "default": "./dist/index.js"
10
13
  }
11
14
  },
@@ -13,11 +16,10 @@
13
16
  "dist"
14
17
  ],
15
18
  "scripts": {
16
- "build": "tsc -p tsconfig.build.json",
17
- "check": "tsc --noEmit",
18
- "test:types": "tsc -p test/types/tsconfig.json",
19
- "test:runtime": "npm run build && tsc -p test/runtime/tsconfig.json && npx tsx test/runtime/has.test.ts",
20
- "test": "npm run test:types && npm run test:runtime",
19
+ "build": "svelte-package -i src -o dist",
20
+ "check": "svelte-check --tsconfig ./tsconfig.json",
21
+ "test:types": "npm run --prefix test build && svelte-check --tsconfig ./test/tsconfig.json",
22
+ "test": "npm run test:types && playwright test",
21
23
  "prepublishOnly": "npm run test && npm run build"
22
24
  },
23
25
  "keywords": [
@@ -33,7 +35,18 @@
33
35
  "type": "git",
34
36
  "url": "https://github.com/robertadamsonsmith/sveltekit-discriminated-fields"
35
37
  },
38
+ "peerDependencies": {
39
+ "@sveltejs/kit": "^2.27.0",
40
+ "svelte": "^5.0.0"
41
+ },
36
42
  "devDependencies": {
43
+ "@playwright/test": "^1.57.0",
44
+ "@sveltejs/package": "^2.0.0",
45
+ "@types/node": "^25.0.3",
46
+ "@typescript-eslint/types": "^8.51.0",
47
+ "playwright": "^1.57.0",
48
+ "svelte": "^5.0.0",
49
+ "svelte-check": "^4.0.0",
37
50
  "typescript": "^5.0.0"
38
51
  }
39
52
  }