rfcs
rfcs copied to clipboard
TypeScript: Explicitly Typing Props/Slots/Events + Generics
Why not use 1 interface for props/events/slots ? This way we can create events, that depends on props:
interface ComponentDefinition<T extends Record<string, string>> {
props: { a: T },
events: { b: T }
}
p.s. Sorry for bad english, tried my best.
Yes this would be possible through the ComponentDef interface
For the slots, props and events, I would lose the Component
prefix:
-
ComponentSlots
->Slots
-
ComponentProps
->Props
-
ComponentEvents
->Events
Then finally ComponentDef
-> Component
.
Doesn't make the names a lot more ambiguous or prone to conflict with existing types I think.
Perhaps separating the type definition from the logic of a component would work nicely.
<script lang="ts" definition>
interface Component<T> {
props: { value: T }
events: {
change: (value: T) => void
}
slots: {
default: {}
}
}
</script>
<script lang="ts">
export let value
</script>
<some>
<slot>Markup</slot>
</some>
I like the shortening of the names although I think this might increase the possibility of name collisions. I'm against putting this into a separate script. This would require more effort in preprocessing, also I like the colocation of the interfaces within the instance script.
Can't wait to use this <3
- Basic components without generics means mapping domain models from/to arbitrary component specific structures.
- Passing a component to a slot is impossible so our best workaround for now would be to pass a [Component, ComponentProps] tuple but there's no way to have ComponentProps mean anything right now.
🙏
Could you elaborate on your second point a little more? I'm don't fully understand the use case. And what do you mean by "impossible"? Possible to do in Svelte but the type checker complains?
(unless I missed something)
you can't pass a component instance to a slot so people end up either
- wrapping all their slots with a div on the call site, ending up in a div soup or
- passing a component and its props as a non slot prop:
<ParentComponent renderHeader={[SomeSvelteComponent, Props]} />
but today, we have no way to express that Props
should be SomeSvelteComponent's Props beside triple checking manually.
Stumbled upon this and just wanted to throw here a slight variation of Option 2:
<script lang="ts">
import {createEventDispatcher} from "svelte";
type T = ComponentGeneric<boolean>; // extends boolean
type X = ComponentGeneric; // any
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
I think it would be slightly closer to TypeScript code than a ComponentGenerics
interface that gets magically expanded :smile:
Hi !
Any idea when Generic Component will be available/released? Is it perhaps a case of choice paralysis? I think we would all love something even if it's not 100% perfect!!
I'm working on a SvelteTS project for several weeks now, and I would have used this feature a few times already. Btw, love the work you're doing to make SvelteTS a thing. TS support was the thing that made me switch from React to Svelte. 🤗
@tomblachut tagging you since you are the maintainer of the IntelliJ Svelte Plugin - anything in that proposal that concerns you implementation-wise ("not possible to implement on our end")?
@dummdidumm thank you for tagging me.
Generics will definitely be, as you've written, an uncanny valley and maintenance burden.
Option 1 & 2 without Svelte support in editor will produce invalid TS, given that both will require special reference resolution. I think it's better to avoid that.
I'd scratch Option 2, because ComponentGenerics is in the same scope as things that will refer to its type parameters. I imagine it will add some implementation complexity AND mental overhead for users.
I quite like Option 3 because it's valid TS. ComponentGeneric would be treated as identity mapped type.
type T = ComponentGeneric<boolean>; // extends boolean
type X = ComponentGeneric; // any
Option 3 could be even simplified a bit by giving new semantics to export type
in similar way as export let
denotes a prop
export type T = boolean;
export type X = any;
Now, I think it's better to stick to one style of declarations: (separate interfaces/compound ComponentDef/namespace) otherwise we may introduce small bugs in one of them and more importantly will need to decide on and teach about precedence.
One additional thing this proposal does not mention is ability to extend interfaces. I think that's great feature. Author of the component could say "this "PromotedPost adheres to Post props" and whenever types are changed in Post definition, implementing components would show type errors. Unless I'm missing something interfaces will support that use case out of the box.
Thanks for your insights!
I agree that we should only provide one style of declarations. Separate interfaces feels like the best option there.
I also agree that for generics option 3 feels the closest to vanilla TypeScript which is why I prefer that, too. That simplification does not feel quite right for me though, because we are not exporting that generic to anyone, we are just stating that the component is generic. Option 3 has one little shortcoming though, being not being strict enough. Take this code snippet:
type T = ComponentGeneric<{a: boolean}>;
const t: T = {a: true};
Without extra Svelte-specific typing-work, this snippet would not error, because TS does not think of T
as a generic. If it did, it would error with Type '{ a: true; }' is not assignable to type 'T'. '{ a: true; }' is assignable to the constraint of type 'T', but 'T' could be instantiated with a different subtype of constraint '{ a: boolean; }'
. In general, errors related to T
would not be in the context of generics. I'd say this is a hit we can take though, because the types are "good enough" for most use cases, and with some extra transformation work (like svelte2tsx
) you can even make TS think that this is a generic - and also it's just not possible to make TS think of T
as a generic at the top level without transformations.
One thing you brought up about extending interfaces is a very good advantage, but it also got me thinking how to deal with generics in that context.
For example you have this interface:
export interface ListProps<ListElement> {
list: ListElement[];
}
How do you extend it while keeping it generic in the context of a Svelte component? The only possibility that comes to my mind is to do this:
<script lang="ts">
import type { ListProps } from '..';
type BooleanListElement = ComponentGeneric<boolean>;
interface ComponentProps extends ListProps<BooleanListElement> {}
export let list: BooleanListElement[];
</script>
..
I miss generics too. I'll leave one example which I would really appreciate to use generics with:
This checkbox selector returns subset of an array of objects without mutating them, which is really handy, but it will return any[]
instead of passing types forward, which is sadge... :disappointed:
<script>
import Checkbox from '../components/Checkbox.svelte';
export let checkboxes = [];
export let checked = [];
export let idF = 'id';
export let textF = 'text';
let checkedIds = new Set(checked.map((c) => c[idF]));
function mark(id) {
checkedIds = new Set(
checkedIds.has(id)
? [...checkedIds].filter((cid) => cid !== id)
: [...checkedIds, id]
);
}
$: checked = checkboxes.filter((c) => checkedIds.has(c[idF]));
</script>
<ul class="checkboxes">
{#each checkboxes as checkbox (checkbox[idF])}
<li>
<Checkbox
checked={checkedIds.has(checkbox[idF])}
on:change={() => mark(checkbox[idF])}
desc={checkbox[textF]}
/>
</li>
{/each}
</ul>
This is proposed way to use generics? I don't think I understood examples correctly, so I added type definitions to this component.
<script lang="ts">
import Checkbox from '../components/Checkbox.svelte';
type Item = ComponentGeneric; // how this will attach to the checkboxes prop ?
export let checkboxes: Item[] = [];
export let checked: Item[] = [];
export let idF = 'id';
export let textF = 'text';
let checkedIds = new Set<number>(checked.map((c: Item) => c[idF]));
function mark(id: number) {
checkedIds = new Set(
checkedIds.has(id)
? [...checkedIds].filter((cid: number) => cid !== id)
: [...checkedIds, id]
):
}
$: checked = checkboxes.filter((c: Item) => checkedIds.has(c[idF]));
</script>
Everything inside this proposal is type-only, which means it's only there to assist you at compile time to find errors early - nothing of this proposal will be usable at runtime, similar to how all TypeScript types will be gone at runtime.
In your example you would do:
<script lang="ts">
// ..
type Item = ComponentGeneric;
type ItemKey = ComponentGeneric<keyof Item>;
export let checkboxes: Item[] = [];
export let checked: Item[] = [];
// Note: For the following props, the default value is left out because you cannot expect "id" or "text" to be present as a default if you don't narrow the Item type
export let idF: ItemKey;
export let textF: ItemKey;
// ..
Wow, keyof is cool.
I want to make sure that I understand correctly, so here's another example:
<script lang="ts">
interface Vegetables {
id: number;
name: string;
weight: number;
}
let someItems: Vegetables[] = [
...
];
let checked: Vegetables[] = [];
</script>
<CheckboxSelector checkboxes={someItems} textF="name" idF="id" bind:checked />
<!-- ^ ^
| | Could it attach to this?
|
| how do I specify that ComponentGeneric
should attach to this?
-->
For example here's how I would use this in regular typescript, which is clear to me.
function component<T>(checkboxes: T[] = [], idF: <keyof T>, textF: <keyof T>) {
...
}
When using the component, you don't specify anything, you just use the component and the types should be inferred and errors should be thrown if the relationship between is incorrect. So in your example if you do textF="nope"
it would error that nope
is not a key of Vegetables
.
Doing
type T = ComponentGeneric;
export let checked: T[];
export let idF: keyof T;
// ...
Would be in the context of Svelte components semantically the same as
function component<T>(checked: T[], idF: keyof T) {
...
}
This is the uncanny valley I'm talking about in the RFC which I fear is unavoidable - it doesn't feel the same like generics, yet it serves this purpose inside Svelte components (and inside only Svelte components). The problem is that Svelte's nice syntax of just directly starting with the implementation without needing something like a wrapping function, so there is no place to nicely attach generics.
So if I end up in a situation where I need two generics:
function component<X, Y>(items: X[], someOtherProp: Y) {
...
}
How that would look in the proposed approach? :thinking: Is it even possible ?
Do you know how bindings will work in the current state?
In the example above I added annotation that checked
in parent component is of same type as someItems
, but if we pass someItems
through checkboxes
prop, we should lose the type, because we can only use any as 'generic' type now in the CheckboxSelector
component, right ?
Do annotations to a bind override any[]
in this case ? :thinking:
Sorry for the wording...
So if I end up in a situation where I need two generics:
function component<X, Y>(items: X[], someOtherProp: Y) { ... }
How that would look in the proposed approach? 🤔 Is it even possible ?
You do this
type X = ComponentGeneric;
type Y = ComponentGeneric;
export let items: X[];
export let someOtherProp: Y;
Do you know how bindings will work in the current state?
In the example above I added annotation that
checked
in parent component is of same type assomeItems
, but if we passsomeItems
throughcheckboxes
prop, we should lose the type, because we can only use any as 'generic' type now in theCheckboxSelector
component, right ?Do annotations to a bind override
any[]
in this case ? 🤔Sorry for the wording...
Sorry, I don't know what you mean.
Sorry, I don't know what you mean.
I hope this is a better way to explain. :thinking:
<script lang="ts">
interface Vegetables {
id: number;
name: string;
weight: number;
}
let someItems: Vegetables[] = [
...
];
// I annotate same type to the checked prop, which I will bind below
// What type checked will have after the bind ?
let checked: Vegetables[] = [];
// I need to use checked in other places and want it to retain the type
</script>
<CheckboxSelector checkboxes={someItems} textF="name" idF="id" bind:checked />
<!-- ^
binding checked here
-->
<script lang="ts">
// ...
// I set any here because I want to accept any object type
// and generics is currently not supported
export let checkboxes: any[] = [];
// this prop will contain a subset of checkboxes prop,
// which I bind above
export let checked: any[] = [];
// ...
</script>
To me this sounds like you mix up some of TypeScript's type system with regular JavaScript. For example let checked = Vegetables[]
is not valid TypeScript, because Vegetables
is an interface, which does not exist at runtime. It should be let checked: Vegetable[]
, which also answers your second question: The type will stay Vegetable
because you told TypeScript that it's of this type, that does not change. Instead, TypeScript would a compiler error if you would try to assign something to checked
that does not suffice the Vegetable
interface (any
is okay, because you can assign any
to anything).
To me this sounds like you mix up some of TypeScript's type system with regular JavaScript.
I'm just making mistakes, not used to type let something: type[] = [];
:grin:
Thanks for the explanation. That's better than nothing.
So looks like the most negatively affected use cases without generics are:
- passing types down through some wrapper component like
svelte-viewpoint
- slot let templates for generic arrays
?
Generics come in handy if you want connect types of 2 or more things to each other. Fo example you may want to guarantee that some event's payload will be of the same type as a prop, or some derivative of that type.
@dummdidumm
Option 3 has one little shortcoming though, being not being strict enough.
Ouch, TIL. Thanks for catching that. Actually not "TIL", I spend couple of days pondering about that 😅 I agree that it's a hit we can take since this problem has additive solution. We can wire up annotations for this on top of normal TS, in contrast to suppressing some false-negatives. 👍
export
has one advantage over ComponentGeneric
- it's not a reference. Though, if it's okay to enable import { ComponentGeneric } from 'svelte';
I'm okay with both approaches. I'd just prefer to avoid global implicit type.
<script lang="ts">
import type { ListProps } from '..';
type BooleanListElement = ComponentGeneric<boolean>;
interface ComponentProps extends ListProps<BooleanListElement> {}
export let list: BooleanListElement[];
</script>
I think your example is elegant.
I didn't consider yet how extends will integrate with SvelteComponentTyped
. Those 3 interfaces would need to be translated into generics of that class. Will it even work?
Implicit global type - I'm a little split on this. There are some other globals already after all, on the other side it's more explicit. Either way, its presence would need extra tooling code (that "it's not the same in TS" thing I mentioned earlier).
About adding it to SvelteComponentTyped
: Do you mean from a tooling perspective how to automatically generate type definitions for libraries, or how to generate the code for the IDE? Either way, some additional logic would be necessary to first extract the interface out to the top level so they can be used on the class, and then another step where the ComponentGeneric
usages are collected and added in order to SvelteComponentTyped
like export class MyComponent<T, X extends string> extends SvelteComponentTyped<{ prop: T },..
. Interfaces could be extracted the same, so that
type T = ComponentGeneric;
interface ComponentEvents {
foo: CustomEvent<T>
}
Becomes
interface ComponentEvents<T> ...
export class MyComponent<T> extends <.. ComponentEvents<T>..
I understand wanting to use as little extra grammar as possible, but I think we should consider using export
to signify a generic type and for specifying the interface of props, slots, and events.
This has a few benefits:
- avoids the use of magic reserved types
- allows for default types
- fully backwards compatible, since it is currently invalid syntax
- use shorter name for the interfaces for slots, props, and events
<script lang="ts">
import {createEventDispatcher} from "svelte";
// required generic type extending boolean
export type T extends boolean;
// optional generic type
// defaults to string
export type X = string;
// instead of ComponentSlots
export interface Slots {
default: { aSlot: T }
}
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
The only thing in the example that isn't valid typescript syntax is export type T extends boolean
, which IMO is not asking for much.
Adding invalid TS syntax is a no-go for me, it would require every IDE to know of this and people need to get used to that for Svelte only. Moreover, there wouldn't be things like optional generics. Generics need to be driven by the prop input.
About the exports: I'm hesitant because exports are right now reserved for props. On the module level, you can also export interfaces/types, but the meaning is different. I'm not sure if it would bring more confusion than helping. On the other hand one could argue that it does align with the "public API" semantics of export let
.
If we go with ComponentGeneric
I think I have one more argument in favor of implicit global: If it's an explicit import, people might think they can model their API like this outside of Svelte components, which is wrong. With a global type, they would see that TS will throw an error ("unknown type") if you try that outside.
Just throwing stuff at the wall...
<script lang="ts">
import {createEventDispatcher} from "svelte";
// this is wacky as all hell but kinda sorta less globals (syntax is more rigid??)
type T<V extends boolean = Generic> = V
type X<V = Generic> = string
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
Honestly, this is the only alternative I could come up with. I think ComponentGeneric
is probably the way to go unless anyone gets any ideas or an epiphany. Maybe my attempt will inspire someone, lol.
EDIT: Hm, actually, this should work too:
<script lang="ts">
import {createEventDispatcher} from "svelte";
export type T<V extends boolean = Generic> = V
export type X = string
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
It's a bit nicer if you have default values, but I still don't think it's good enough. The noise coming from declaring a type parameter is pretty high.
~~also I prefer the export type
solution in general as it has a lot less noise dont at me~~
Also, wouldn't using $$
prefixes instead of Component
match Svelte patterns better?
<script lang="ts">
import {createEventDispatcher} from "svelte";
type T = $$Generic<boolean>; // extends boolean
type X = $$Generic; // any
// you can use generics inside the other interfaces
interface $$Slots {
default: { aSlot: T }
}
export let array1: T[];
export let item1: T;
export let array2: X[];
const dispatch = createEventDispatcher<{arrayItemClick: X}>();
</script>
Adding invalid TS syntax is a no-go for me, it would require every IDE to know of this and people need to get used to that for Svelte only. Moreover, there wouldn't be things like optional generics. Generics need to be driven by the prop input.
Can you explain more why optional generics shouldn't be a thing? I'm not quite grasping your reasoning.
But if there aren't optional types, you can just use the right hand side as the base type.
export type T = string; // generic type T extends string
I'm hesitant because exports are right now reserved for props. On the module level, you can also export interfaces/types, but the meaning is different. I'm not sure if it would bring more confusion than helping. On the other hand one could argue that it does align with the "public API" semantics of export let.
They're modeled explicitly after the way props work. I think following that model would make following this more intuitive and more discoverable. For instance, setting a generic could be just like using a prop:
// Foo.svelte
<script lang="ts">
export type X = string;
</script>
// Bar.svelte
<script lang="ts">
import Foo from './foo';
interface Stuff {}
</script>
<Foo X={Stuff} />
I don't think setting them explicitly is planned as part of this rfc it's just an example to demonstrate my line of thinking.
Before I knew this wasn't possible yet, I tried doing stuff similar to this (to be met with errors of course).