Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Destructuring of discriminated unions in the presence of bindable props in Svelte 5 #15675

Open
rudolfbyker opened this issue Apr 3, 2025 · 4 comments

Comments

@rudolfbyker
Copy link

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

@rudolfbyker rudolfbyker changed the title Destructuring of discriminated unions in the presence of bindable props Destructuring of discriminated unions in the presence of bindable props in Svelte 5 Apr 3, 2025
@7nik
Copy link
Contributor

7nik commented Apr 3, 2025

You can typecast binding in the template. Not the best solution, but it works with both Svelte and TS.
Note, input's bind:value already has type any, so you need typecast only bind:checked and in <script>.

<script lang="ts">
	type Props = ...;

	let { userInputType, value = $bindable() }: Props = $props();

	if (userInputType === "float") {
		const a = value as number;
		console.log(a);
	}
</script>


{#if userInputType === "boolean"}
	<input type="checkbox" bind:checked={value as boolean} />
{:else if userInputType === "string"}
	<input class="input" type="text" bind:value={value} />
{:else if userInputType === "int"}
	<input class="input" type="number" step="1" bind:value={value} />
{:else if userInputType === "float"}
	<input class="input" type="number" bind:value={value} />
{:else}
	<span></span>
{/if}

@rudolfbyker
Copy link
Author

Thanks for the quick response!

You can typecast binding in the template.

This causes an error in my IDE, so I assumed that it will error at runtime, too. However, it seems that svelte-check is OK with it.

Image

It seems that the Svelte plugin for IDEA is not completely up to speed with Svelte 5 yet: https://plugins.jetbrains.com/plugin/12375-svelte/reviews

Note, input's bind:value already has type any

Sure, I should have chosen a better example: As soon as you want to extract that input to your own component, then it won't be any any more.


In summary, the issue stands, but I have been helped with a more succinct workaround (i.e., using as in the template).

@dummdidumm
Copy link
Member

The only real solution apart from type gymnastics is either TS gets much smarter about how types relate to each other (AFAIK it already is good at this when you use const but that doesn't obv work with bindings), or we introduce some way to make props bindable without having to destructure (which would introduce two APIs to do the same thing, so unlikely).

@rudolfbyker
Copy link
Author

or we introduce some way to make props bindable without having to destructure

This is what I'm lobbying for :)

(which would introduce two APIs to do the same thing, so unlikely).

Why do you consider my proposal to be a separate API? I understand that $bindable(props.value) and {value = $bindable()} = ... don't use the $bindable() rune in the same way, but it's not like $bindable() is a real function call anyway. It's just a way to flag something for the compiler. I would consider this to be an alternative syntax for the same API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants