-
-
Notifications
You must be signed in to change notification settings - Fork 4.6k
Description
Describe the problem
Context
At #9241 (comment), @Rich-Harris wrote:
In Svelte 5 you can easily do things like discriminated unions...
<!-- Input.svelte --> <script lang="ts"> type Props = { type: 'text'; value: string } | { type: 'number'; value: number }; let { type, value }: Props = $props(); </script> <script lang="ts"> import Input from './Input.svelte'; </script> <!-- cool --> <Input type="text" value="a string" /> <Input type="number" value={42} /> <!-- type error --> <Input type="number" value="a string" /> <Input type="text" value={42} />
...which are completely impossible in Svelte 4. Maybe that doesn't feel like a significant limitation day-to-day, but it impacts the quality of component libraries in the ecosystem
Now this is exactly the type of thing I'm trying to do, but it's not working as advertised, due to some quirks of Svelte and Typescript not loving each other:
- TypeScript loses the connection between some types when destructuring a discriminated union, causing type narrowing to not do its thing.
- We can't make a prop bindable without destructuring.
Example 1: No destructuring, not bindable.
Type narrowing works fine, but value
is not bindable:
<script lang="ts">
type Props =
| {
userInputType: "string";
value: string;
}
| {
userInputType: "boolean";
value: boolean;
}
| {
userInputType: "float";
value: number;
}
| {
userInputType: "int";
value: number;
};
let props: Props = $props();
if (props.userInputType === "float") {
const a: number = props.value;
console.log(a);
}
</script>
<td>
{#if props.userInputType === "boolean"}
<input type="checkbox" bind:checked={props.value} />
{:else if props.userInputType === "string"}
<input class="input" type="text" bind:value={props.value} />
{:else if props.userInputType === "int"}
<input class="input" type="number" step="1" bind:value={props.value} />
{:else if props.userInputType === "float"}
<input class="input" type="number" bind:value={props.value} />
{:else}
<span></span>
{/if}
</td>
Using the component causes an error:
<script lang="ts">
let example = $state(false);
$effect(() => {
console.log("Example value changed:", example);
});
</script>
<Example userInputType="boolean" bind:value={example} />
Svelte: Cannot use 'bind:' with this property. It is declared as non-bindable inside the component. To mark a property as bindable: 'let { value = $bindable() } = $props()'
Example 2: Bindable, no destructuring.
If I follow the advice from the error message above, and the documentation of $bindable
, the type narrowing stops working. See the error messages in the comments:
<script lang="ts">
type Props = ...; // omitted for brevity
let { userInputType, value = $bindable() }: Props = $props();
if (userInputType === "float") {
// Svelte: Type string | number | boolean is not assignable to type number
// Type string is not assignable to type number
const a: number = value;
console.log(a);
}
</script>
<td>
{#if userInputType === "boolean"}
<!--
Svelte: Type string | number | boolean is not assignable to type boolean | null | undefined
Type string is not assignable to type boolean | null | undefined
-->
<input type="checkbox" bind:checked={value} />
{:else if userInputType === "string"}
<!-- omitted for brevity -->
{/if}
</td>
Example 3: Having both a destructured and a non-destructured version of the props is not possible
let props: Props = $props();
// Svelte: `$bindable()` can only be used inside a `$props()` declaration
let { userInputType, value = $bindable() }: Props = props;
Example 4: Restructuring the destructured props is hacky and breaks reactivity
<script lang="ts">
type Props = ...; // omitted for brevity
let { userInputType, value = $bindable() }: Props = $props();
// Svelte: Type { ... } is not assignable to type Props
// We could force this with `@ts-expect-error`.
let restructuredProps: Props = { userInputType, value };
if (restructuredProps.userInputType === "float") {
// This works now!
const a: number = restructuredProps.value;
console.log(a);
}
</script>
<td>
{#if restructuredProps.userInputType === "boolean"}
<!-- `bind:checked={restructuredProps.value}` is binding to a non-reactive property -->
<input type="checkbox" bind:checked={restructuredProps.value} />
{:else if restructuredProps.userInputType === "string"}
<!-- omitted for brevity -->
{/if}
</td>
Workaround 1: Use type any
:(
This removes a lot of type safety:
interface Props {
userInputType: "string" | "boolean" | "float" | "int";
value: any;
};
Workaround 2: Use custom two-way reactivity
I'm not even sure if this is correct:
let { userInputType, value = $bindable() }: Props = $props();
// valueBoolean is a boolean version of `value`, to satisfy type checking.
let valueBoolean = $state(!!value);
$effect(() => {
if (userInputType !== "boolean") return;
valueBoolean = !!value;
});
$effect(() => {
if (userInputType !== "boolean") return;
value = valueBoolean;
});
In Vue, I would have used a WritableComputed
for this:
const valueBoolean = computed({
get: () => !!value,
set: (v) => { value = v; },
});
Describe the proposed solution
Possible solution 1
Allow $bindable()
to be used like this:
let props: Props = $props();
$bindable(props.value);
This currently causes:
Svelte:
$bindable()
can only be used inside a$props()
declaration
Possible solution 2
- Allow type assertions (e.g.,
as any
) inside the template. - Allow comments like
@ts-expect-error
and@ts-ignore
inside the template.
Possible solution 3
I could not think of any others yet.
Importance
would make my life easier