svelte
svelte copied to clipboard
TypeScript no way to define that component props implement a certain interface
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
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
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
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.
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>
)
}
"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)
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!
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?
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 🤷♂️.
Both these examples do what you want, they produce a type error
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.
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}
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
Svelte 5 fixes this with the $props()
rune, so I'll close this