svelte icon indicating copy to clipboard operation
svelte copied to clipboard

TypeScript no way to define that component props implement a certain interface

Open seeker-3 opened this issue 2 years ago • 9 comments

Describe the problem

I'm not sure if there's a solution to this problem, but when I want to spread an object into a component, I'm finding the currently way of declaring those props a little verbose and unsafe.

<!-- List.svelte -->
<script lang="ts" context="module">
  export interface ItemData {
    x: number
    y: number
    z: number
  }
</script>

<script lang="ts">
  import ListItem from './ListItem.svelte'

  let list: ItemData[] = [
    { x: 1, y: 1, z: 1 },
    { x: 1, y: 1, z: 1 },
  ]
</script>

<ul>
  {#each list as item}
    <ListItem {...item} />
  {/each}
</ul>
<!-- ListItem.svelte -->
<script lang="ts">
  import type { ItemData } from './List.svelte'
  // Is there a better way to define these props?
  export let x: ItemData['x']
  export let y: ItemData['y']
  export let z: ItemData['z']
</script>

<li>
  x: {x} y: {y} z: {z}
</li>

I say unsafe because when I defined the props, I get no indicator that I've exhausted all the props or if I add more props to my data interface, same problem, it's not going to give me a type error in the component that I haven't declared component props for all the props types from the interface.

Describe the proposed solution

It looks like svelte generates types when I define a component Screen Shot 2022-06-14 at 4 26 43 PM

But it would be nice if there was some to manipulate the types svelte generated with some sort of generics slots over overload.

A couple possibilities:

<script lang="ts" context="module">
  import type { ItemData } from './List.svelte'

  function render(): {
    props: ItemData
    slots: {}
    getters: {}
    events: {}
  }
</script>

<script lang="ts">
  export let x // these would be inferred by the overload above
  export let y
  export let z
</script>

<li>
  x: {x} y: {y} z: {z}
</li>
<script lang="ts" context="module">
  import type { ItemData } from './List.svelte'
</script>

<script lang="ts" props="{ItemData}">
  export let x
  export let y
  export let z
</script>

<li>
  x: {x} y: {y} z: {z}
</li>
<script lang="ts" props="{import('./List.svelte').ItemData}">
  export let x
  export let y
  export let z
</script>

<li>
  x: {x} y: {y} z: {z}
</li>

The argument I'm making is mainly pertaining to props, but it would probably be nice to be able to manipulate all types the typescript compiler is generating for the component (slots, getters, events). From my experience, the TSC is great at type inference, but sometimes it needs a little help.

Alternatives considered

Instead of spreading the object into the component, just pass it as a whole prop

<!-- List.svelte -->
<script lang="ts" context="module">
  export interface ItemData {
    x: number
    y: number
    z: number
  }
</script>

<script lang="ts">
  import ListItem from './ListItem.svelte'

  let list: ItemData[] = [
    { x: 1, y: 1, z: 1 },
    { x: 1, y: 1, z: 1 },
  ]
</script>

<ul>
  {#each list as itemData}
    <ListItem {itemData} />
  {/each}
</ul>
<!-- ListItem.svelte -->
<script lang="ts">
  import type { ItemData } from './List.svelte'

  // Alternative choice
  export let itemData: ItemData
  $: ({ x, y, z } = itemData)
</script>

<li>
  x: {x} y: {y} z: {z}
</li>

Importance

would make my life easier

seeker-3 avatar Jun 14 '22 21:06 seeker-3

What's the use case for this? Your workaround sounds like what you would want in this case.

You can achieve what you want by using the experimental $$Props type, you'd write type $$Props = ListItem. More info here: https://github.com/sveltejs/rfcs/pull/38

dummdidumm avatar Jun 14 '22 21:06 dummdidumm

Okay cool. Looks like there's already some traction on this issue. The use case would be defining multiple props from an object interface you are going to ...spread from. Otherwise spreading is clunky and unsafe.

seeker-3 avatar Jun 14 '22 22:06 seeker-3

Here's a react tsx example

import type { FC } from 'react'

interface ItemData {
  x: number
  y: number
  z: number
}

const list: ItemData[] = [
  { x: 1, y: 1, z: 1 },
  { x: 1, y: 1, z: 1 },
]

interface OtherProps {}

const ListItem: FC<ItemData & OtherProps> = ({ x, y, z }) => {
  return (
    <li>
      x: {x} y: {y} z: {z}
    </li>
  )
}

const List: FC = () => {
  return (
    <ul>
      {list.map(item => (
        <ListItem {...item} />
      ))}
    </ul>
  )
}

seeker-3 avatar Jun 14 '22 22:06 seeker-3

"Generic Slots for Svelte Components" sounds like a misnomer to me then. If I understand you correctly, you want some way to tell Svelte components that they need to implement a certain interface. This is what the $$Props I mentioned are for. You can use this today already:

<!-- ListItem.svelte -->
<script lang="ts">
  import type { ItemData } from './List.svelte'

  interface $$Props extends ItemData {}

  export let x: $$Props['x'];
  export let y: $$Props['y'];
  export let z: $$Props['z'];
</script>

<li>
  x: {x} y: {y} z: {z}
</li>

What's missing yet is automatic inference of the prop type so you don't have to write it twice (in other words, a way that $$Props['x'] is unnecessary)

dummdidumm avatar Jun 15 '22 05:06 dummdidumm

Maybe not the best titling of the issue. In vanilla TS or TSX these would probably be type slots for generics, but svelte kind of has it's own "magic" way of doing things (which I think is cooler). You are welcome to give it a more appropriate name.

I am still learning svelte, and I was not sure if there was already a way to do this. I posted on the Discord first and I didn't get any good responses. Thanks for helping me out!

seeker-3 avatar Jun 15 '22 05:06 seeker-3

What's missing yet is automatic inference of the prop type so you don't have to write it twice (in other words, a way that $$Props['x'] is unnecessary)

What would you write then? Just export let x; ? I guess the svelte extension would handle it, but how would typescript infer the type?

Etchelon avatar Jun 16 '22 11:06 Etchelon

That does seem tricky. The type inference would be NICE. But I probably care more about type safety than the inference initially, which seems like more of a svelte language tool feature rather than a typescript feature.

Some examples:

Error: Prop booleanValue is not defined

<script lang="ts">
  interface $$Props {
    stringValue: string
    numberValue: number
    booleanValue: boolean
  }

  export let stringValue: string
  export let numberValue: number
</script>

Error: numberValue type declarations are incompatible.

<script lang="ts">
  interface $$Props {
    stringValue: string
    numberValue: number
    booleanValue: boolean
  }

  export let stringValue: string
  export let numberValue: string
  export let booleanValue: boolean
</script>

Error: svelte component has no prop unknownValue

<script lang="ts">
  interface $$Props {
    stringValue: string
    numberValue: number
    booleanValue: boolean
  }

  export let stringValue: string
  export let numberValue: number
  export let booleanValue: boolean
  export let unknownValue: unknown 
</script>

The last one is a little subjective, but if I declared a $$Props interface, I'd probably want it to be the "source of truth" for that component 🤷‍♂️.

seeker-3 avatar Jun 17 '22 01:06 seeker-3

Both these examples do what you want, they produce a type error

dummdidumm avatar Jun 17 '22 16:06 dummdidumm

This could be useful in combination with svelte:component where the used component is dynamic, like in this far fetched example for demo purposes:

<script lang="ts">
  import type { SvelteComponent } from 'svelte';
  // type $$Props = Vehicle
  import Bike from '$lib/Bike.svelte';
  // type $$Props = Vehicle & { fuel: string }
  import Car from '$lib/Car.svelte';
  
  let selected = Car; // [1]
  let selected: typeof SvelteComponent = Car;  // [2]
</script>

<select bind:value={selected}>
   <option value={Car}>Car</option>
   <option value={Bike}>Bike</option>
</select>

<svelte:component this={selected} fuel="gasoline" speed={123} range={456} />

Now, because Bike doesn't have the prop fuel typescript should shout at me, but in both cases it is doing it wrong:

[1] let selected = Car In this case selected will be of type Car, which has fuel defined and therefore I can use it as a prop

[2] let selected : typeof SvelteComponent = Car Now it has just become a generic component and I don't get anything at all anymore!

With a more baked in way of doing this, it might be possible to do something like:

let selected: typeof SvelteComponent<Vehicle> = Car

I think this can help when dealing with array of components, or with selectors like above.

stephane-vanraes avatar Dec 20 '22 22:12 stephane-vanraes

I'm interested in a similar use case, except for enforcing an interface of functions on a list of svelte:component, and not as props.

// Demo.ts (DemoControls interface not used in this example)
export interface DemoControls {
  reset(): void;
  start(): void;
}

export class Demo {
  demoComponent?: SvelteComponent;

  constructor(
    public name: string,
    public demoType: ComponentType,
  ) {}
}
// WaypointsDemo.svelte
<script lang="ts">
  export function reset(): void { ... }
  export function start(): void { ... }
</script>
// +page.svelte
<script lang="ts">
  import type { Demo } from '.demos';
  import WaypointsDemo from './WaypointsDemo.svelte';
  import TravelersDemo from './TravelersDemo.svelte';
  
  const waypointsDemo = new Demo('waypoints', WaypointsDemo);
  const travelersDemo = new Demo('travelers', TravelersDemo);
  const demos = [waypointsDemo, travelersDemo];

  onMount(() => {
    demos.forEach((demo: Demo) => {
      // reset and start are currently being interpreted as 'any'
      demo.demoComponent!.reset();
    });
  });

  function handleDemoClick(demo: Demo): void {
    demo.demoComponent!.start();
  }
</script>

{#each demos as demo}
  <div
    class="{demo.name}-demo-container"
    on:click={(e) => handleDemoClick(demo)}
  >
    <h2>{demo.title}</h2>
    <svelte:component this={demo.demoType} bind:this={demo.demoComponent} />
  </div>
{/each}

anxpara avatar Jan 27 '23 04:01 anxpara

alright, i figured out how to enforce the interface

// Demo.ts
export interface DemoControls {
  reset(): void;
  start(): void;
}

export class Demo {
  demoComponent?: SvelteComponent & DemoControls;

  constructor(
    public name: string,
    public demoType: ComponentType<SvelteComponentTyped & DemoControls>,
  ) {}
}

which correctly throws a compile error if a component is passed to Demo() which doesn't implement the interface

anxpara avatar Jan 27 '23 16:01 anxpara

Svelte 5 fixes this with the $props() rune, so I'll close this

Rich-Harris avatar Apr 02 '24 20:04 Rich-Harris