rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

TypeScript: Explicitly Typing Props/Slots/Events + Generics

Open dummdidumm opened this issue 3 years ago • 90 comments

rendered

dummdidumm avatar Sep 20 '20 15:09 dummdidumm

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.

numfin avatar Sep 27 '20 12:09 numfin

Yes this would be possible through the ComponentDef interface

dummdidumm avatar Sep 27 '20 12:09 dummdidumm

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.

stefan-wullems avatar Sep 27 '20 16:09 stefan-wullems

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>

stefan-wullems avatar Sep 28 '20 08:09 stefan-wullems

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.

dummdidumm avatar Sep 28 '20 13:09 dummdidumm

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.

🙏

AlexGalays avatar Oct 28 '20 17:10 AlexGalays

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?

dummdidumm avatar Oct 28 '20 17:10 dummdidumm

(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.

AlexGalays avatar Oct 28 '20 19:10 AlexGalays

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:

francoislg avatar Nov 17 '20 19:11 francoislg

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. 🤗

denis-mludek avatar Dec 09 '20 15:12 denis-mludek

@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 avatar Feb 12 '21 17:02 dummdidumm

@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.

tomblachut avatar Feb 14 '21 12:02 tomblachut

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>
..

dummdidumm avatar Feb 20 '21 14:02 dummdidumm

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>

non25 avatar Feb 24 '21 01:02 non25

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;
// ..

dummdidumm avatar Feb 24 '21 09:02 dummdidumm

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>) {
  ...
}

non25 avatar Feb 24 '21 15:02 non25

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.

dummdidumm avatar Feb 24 '21 16:02 dummdidumm

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...

non25 avatar Feb 24 '21 16:02 non25

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 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 ? 🤔

Sorry for the wording...

Sorry, I don't know what you mean.

dummdidumm avatar Feb 24 '21 16:02 dummdidumm

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>

non25 avatar Feb 24 '21 16:02 non25

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).

dummdidumm avatar Feb 24 '21 17:02 dummdidumm

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

?

non25 avatar Feb 24 '21 17:02 non25

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.

tomblachut avatar Feb 24 '21 19:02 tomblachut

@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?

tomblachut avatar Feb 24 '21 19:02 tomblachut

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>..

dummdidumm avatar Feb 24 '21 21:02 dummdidumm

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.

pitaj avatar Feb 25 '21 03:02 pitaj

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.

dummdidumm avatar Feb 25 '21 06:02 dummdidumm

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~~

Monkatraz avatar Feb 25 '21 09:02 Monkatraz

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>

Monkatraz avatar Feb 25 '21 09:02 Monkatraz

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).

pitaj avatar Feb 25 '21 14:02 pitaj