svelte icon indicating copy to clipboard operation
svelte copied to clipboard

Add a (singular) `$prop` rune

Open aradalvand opened this issue 2 years ago • 74 comments

Describe the problem

This came up multiple times in the Discord server, so I figured I'd create an issue for it: In v5's runes mode, the only way to declare props is by using the $props rune, like so:

let { someProp = 'default' } = $props();

This looks good unless you're using TypeScript, in which case in order to type your props, you'll have to pass a type argument to $props, what this means in effect is that you'll be duplicating your prop names:

let { someProp = 'default' } = $props<{ someProp: string }>();

Needless to say this is not very pretty, feels a bit overly verbose, it gets even more problematic when you have many props:

let {
    foo = 'default',
    bar,
    buzz = 123,
} = $props<{
    foo: string;
    bar: number;
    buzz: number;
}>();

Here, you have to traverse quite a bit of distance with your eyes in order to find a specific prop's type (e.g. bar), because the destructured variable and the corresponding type could potentially be far from each other, and you'll have to "switch" your eyes to an entirely different "list", if you will.

In addition, this format yields a weird order of name => default-value => name => type, and when you compare all of this to the old way of declaring props with types, the difference becomes apparent:

export let foo: string = 'default';
export let bar: number;
export let buzz: number = 123;

Which is more concise, of course, but the order is more logical as well: name => type => default-value.

Describe the proposed solution

Introduce a singular $prop rune, like so:

let someProp = $prop<string>('default');

Which, once again, is less verbose and has a more logical order of symbols; the type of each prop is always next to it on the same line, and you don't have to repeat the prop name.

Alternatives considered

  • Just using the $props rune, which suffers from the aforementioned problems.

Importance

nice to have

aradalvand avatar Sep 21 '23 17:09 aradalvand

Would it be possible for this to work with a type declaration directly on the variable rather than a generic? E.g.:

let someProp: string = $prop('default');

theodorejb avatar Sep 21 '23 19:09 theodorejb

If "hijacking" TypeScript's syntax is an option, I also think this should be a considered syntax:

let {
    foo: string = 'default',
    bar: number,
    buzz: number = 123,
} = $props();

No more duplicate prop names, and the order of name-> type -> value is preserved.

Snailedlt avatar Sep 21 '23 19:09 Snailedlt

Would it be possible for this to work with a type declaration directly on the variable rather than a generic? E.g.:

let someProp: string = $prop('default');

Generally not, that sort of annotation will only be checked for compatibility with the value being assigned, not change the type.

(Example)


@Snailedlt That should further simplify to:

let {
    foo = 'default',
    bar: number,
    buzz = 123,
} = $props();

brunnerh avatar Sep 21 '23 19:09 brunnerh

@Snailedlt That syntax is already reserved by JavaScript for specifying aliases for the destructured properties. It can't be used for specifying types.

aradalvand avatar Sep 21 '23 19:09 aradalvand

Actually, one problem with the singular $prop approach I proposed is that you won't be able to use reserved keywords as prop names — previously you could do that like so:

let classProp: string;
export { classProp as class };

And with the plural $props rune you could do it like this:

let { class: classProp } = $props();

But it doesn't seem like you'd be able to do the same thing with the singular $prop, as I proposed it; this, coupled with the fact the ...rest syntax won't work either (unless we were to also add a $restProps rune or something like that), makes me a little hesitant.

aradalvand avatar Sep 21 '23 19:09 aradalvand

Edit: The following is now an error.


You can use $props multiple times, by the way.

let { foo = 'default' } = $props<{ foo: string }>();
let { bar } = $props<{ bar: number }>();
let { buzz = 123 } = $props<{ buzz: number }>();

Bit verbose, though.

brunnerh avatar Sep 21 '23 19:09 brunnerh

@brunnerh But the prop names are still being repeated, which is the main problem.

aradalvand avatar Sep 21 '23 19:09 aradalvand

@Snailedlt That syntax is already reserved by JavaScript for specifying aliases for the destructured properties. It can't be used for specifying types.

@aradalvand Oh, didn't know that, how about this then?

let {
    foo<string> = 'default',
    bar<number>,
    buzz<number> = 123,
} = $props();

Snailedlt avatar Sep 21 '23 19:09 Snailedlt

@Snailedlt Again, invalid syntax.

aradalvand avatar Sep 21 '23 19:09 aradalvand

What do you think about this syntax?

const klass = $prop<string>("class");
const htmlFor = $prop<string>("for");
const myNumber = $prop<number>("my-number"); // also kebab-case prop names would be possible

<MyComponent class="something" for="input-1" my-number={123} />

TheHadiAhmadi avatar Sep 21 '23 20:09 TheHadiAhmadi

@TheHadiAhmadi Could work. We could also just use ?? [default] for specifying the default value.

let klass = $prop<string>('class') ?? 'default';

Update: I think ?? falls back to the right operand if the left operand is undefined or null, which is different from how default values in object destructuring work (e.g. let { foo = 'default' } = $props()), where it only falls back to the default value if the property is undefined (but not null). So, this idea may not be feasible either, given that.

aradalvand avatar Sep 21 '23 20:09 aradalvand

I'm not even using TypeScript and would prefer having all props neatly separated. In the same way that I would never do let foo, bar; or export let foo, bar; because it is way less scannable (the eyes need to do more horizontal movement instead of just going down and scan over all declarations, yes I'm aware you can split the destructured object over multiple lines). Given that Svelte always follows the "code is read more often than it is written" mantra it is odd that the noise of destructuring was chosen over single props. But I understand that this is my personal preference and obviously we need to keep the $props rune for all kinds of reasons and edge cases.

I'll throw the following in the mix, why not (runes seem to be bending what's considered JavaScript anyway and declaring prop names as strings does not sit well with me):

const regularRequired = $prop<string>();
const reservedClassKeyword = $prop.class<string>();
const withDefault = $prop<number>(10);

But I kind of hate it, you can't immediately see the props this component receives, you need to pay attention to the .class. But I guess that's true for most suggestions, destructuring makes this the easiest due to proximity.

What if $prop also allows destructuring, but only to objects with a single property? These are compiler instructions after all, but we might anger the JavaScript police more and more as I speak:

const regularRequired = $prop<string>();
const { class: reservedClassKeyword } = $prop<string>();
const withDefault = $prop<number>(10);

Prinzhorn avatar Sep 22 '23 07:09 Prinzhorn

We did not add it yet for a couple of reasons:

  • no good way to alias reserved names like class (there's a couple of proposals in this thread, but as the authors themselves point out none of them are great)
  • if only $prop() or $props() can be used (i.e. you can't use both in the same component), then you're in for annoying refactors from $prop() to $props() as soon as you need rest props, or aliases, or want to specify that the component implements a certain interface
  • if both can be used, there need to be elaborate rules in place to deconflict types and definitions (for example so that you can't do const x = $prop<string>(); const { x: _, ...rest } = $props<{ x: number; .. }>())

dummdidumm avatar Sep 22 '23 08:09 dummdidumm

This can be hidden in $props.single(). There is $effect.pre(), so it is similar....

look997 avatar Sep 23 '23 06:09 look997

Is it possible the priorities and trade offs decided are just wrong? Writing a TypeScript component is really common, exporting class is really uncommon (and I'd even argue an anti-pattern in 90% of cases).

Making the stuff I read and write every single time I open a file in a TypeScript project more difficult in exchange for making something I seldom do a little less kludgy (you still have to alias it after all), is a horrible trade off.

WaltzingPenguin avatar Sep 25 '23 05:09 WaltzingPenguin

exporting class is really uncommon

It's really not, I do that all the time when wrapping base elements. Component libraries require this a lot unless they access $$props.

brunnerh avatar Sep 25 '23 08:09 brunnerh

exporting class is really uncommon (and I'd even argue an anti-pattern in 90% of cases).

I strenuously disagree with both of those assertions — I would refer you to #6972.

aradalvand avatar Sep 25 '23 15:09 aradalvand

I'm not even using TypeScript and would prefer having all props neatly separated. In the same way that I would never do let foo, bar; or export let foo, bar; because it is way less scannable (the eyes need to do more horizontal movement instead of just going down and scan over all declarations, yes I'm aware you can split the destructured object over multiple lines). Given that Svelte always follows the "code is read more often than it is written" mantra it is odd that the noise of destructuring was chosen over single props. But I understand that this is my personal preference and obviously we need to keep the $props rune for all kinds of reasons and edge cases.

I'll throw the following in the mix, why not (runes seem to be bending what's considered JavaScript anyway and declaring prop names as strings does not sit well with me):

const regularRequired = $prop<string>();
const reservedClassKeyword = $prop.class<string>();
const withDefault = $prop<number>(10);

But I kind of hate it, you can't immediately see the props this component receives, you need to pay attention to the .class.

I feel like we're getting closer, but do we need to restrict ourself to only one "rune" for this task? As popular as reserved words may be in some cases (I agree "class" certainly is - and I don't think it should be considered an antipattern), for the most part they're still an edge case, so maybe better not to pollute the behavior of the default non-aliased $prop and just provide an additional "rune", e.g. something like $aliasedProp?

const regularRequired = $prop<string>();
const regularWithDefault = $prop<number>(10);
const aliasedRequired = $aliasedProp('class')<string>();
const aliasedWithDefault = $aliasedProp('catch')<boolean>(false);

An alternative signature for $aliasedProp could take two parameters, <T>(alias: string, defaultValue?: T) => T, but I slightly prefer the curried version I used in the example, (alias: string) => <T>(defaultValue?: T) => T, because with this version it's less likely the alias would be confused for the default value.

MaximSagan avatar Sep 28 '23 07:09 MaximSagan

Agree with having $aliasedProp or $prop.alias but highly disagree on the curry syntax as it looks busier with them.

intrnl avatar Sep 28 '23 16:09 intrnl

I feel like we're getting closer, but do we need to restrict ourself to only one "rune" for this task? As popular as reserved words may be in some cases (I agree "class" certainly is - and I don't think it should be considered an antipattern), for the most part they're still an edge case, so maybe better not to pollute the behavior of the default non-aliased $prop and just provide an additional "rune", e.g. something like $aliasedProp?

const regularRequired = $prop<string>();
const regularWithDefault = $prop<number>(10);
const aliasedRequired = $aliasedProp('class')<string>();
const aliasedWithDefault = $aliasedProp('catch')<boolean>(false);

An alternative signature for $aliasedProp could take two parameters, <T>(alias: string, defaultValue?: T) => T, but I slightly prefer the curried version I used in the example, (alias: string) => <T>(defaultValue?: T) => T, because with this version it's less likely the alias would be confused for the default value.

How about the following:

const regularRequired = $prop<string>();
const regularWithDefault = $prop(10);

const aliasedRequired = $prop<string>().as('class');
const aliasedWithDefault = $prop(false).as('catch');

Since the exposed property names for the component need to be statically analyzed anyway, we can do pretty much anything, as long as it's valid js syntax.

Jak-Ch-ll avatar Oct 09 '23 13:10 Jak-Ch-ll

When it comes to this discussion it's also probably worthwhile to check out the unofficial experimental defineProp macro for Vue: https://vue-macros.sxzz.moe/macros/define-prop.html

Jak-Ch-ll avatar Oct 09 '23 13:10 Jak-Ch-ll

we can do pretty much anything, as long as it's valid js syntax.

That is not quite correct. In terms of types, runes expose the underlying values, so you cannot have chaining like that and still preserve the simple case without it.

E.g. $prop(10) will have the type number and thus no as function, so unless all types are polluted with those fake functions, that will cause issues. (Even then I was unable to get the typing to work as the this return type appears to not resolve correctly.)

brunnerh avatar Oct 09 '23 14:10 brunnerh

we can do pretty much anything, as long as it's valid js syntax.

That is not quite correct. In terms of types, runes expose the underlying values, so you cannot have chaining like that and still preserve the simple case without it.

E.g. $prop(10) will have the type number and thus no as function, so unless all types are polluted with those fake functions, that will cause issues. (Even then I was unable to get the typing to work as the this return type appears to not resolve correctly.)

Ah, you are right, I did not take the types into consideration. Then let me throw in another idea, which is a somewhat revised version of yours and takes additional influences from the Vue defineProp macro:

const aliasedRequired = $prop<string>(undefined, {
  name: 'class'
})
const aliasedWithDefault = $prop(false, {
  name: 'catch'
})

The main advantages are, that there is no need for an additional rune and, at least from my point of view, it's highly readable and intuitive what's going on.

The obvious disadvantage is that the syntax is somewhat more verbose, especially having to add undefined for required props. But as these renames should be the exception, I don't see this as a massive downside.

Another advantage is, that this syntax is extendable for future features, e.g. a runtime validation, similar to Vue

Jak-Ch-ll avatar Oct 09 '23 20:10 Jak-Ch-ll

Ah, you are right, I did not take the types into consideration. Then let me throw in another idea, which is a somewhat revised version of yours and takes additional influences from the Vue defineProp macro:

const aliasedRequired = $prop<string>(undefined, {
  name: 'class'
})
const aliasedWithDefault = $prop(false, {
  name: 'catch'
})

One thing I'm not certain about with this syntax is, without Typescript, how is Svelte supposed to know that aliasRequired is required, or if it has just been defaulted to the value of undefined?

Not-Jayden avatar Oct 12 '23 13:10 Not-Jayden

Good point regarding the default/required issue. We could maybe use a different syntax here, utilizing ?? 🤔

declare function $prop<T = undefined>(options?: any): T;

let required = $prop<number>();
let withDefault = $prop<number>() ?? 42;
let withDefaultImplicit = $prop() ?? 42;

let aliasedRequired = $prop<string>({ name: 'class '});
let aliasedWithDefault = $prop({ name: 'class '}) ?? 'flex';

// ?? removes undefined and null from the type
let requiredAllowUndefined = $prop<number | undefined>();
let requiredAllowNull = $prop<number | null>();
let withDefaultAllowUndefined = $prop() ?? (42 as number | undefined);
let withDefaultAllowNull = $prop() ?? (42 as number | null);

TS Playground

brunnerh avatar Oct 12 '23 14:10 brunnerh

  1. ?? will replace both null and undefined with the default value, while the default value at restructuring replaces only undefined.
  2. The as operator does type casting with either type narrowing or extending and shouldn't be used at variable initialization unless you really want it. Example.

7nik avatar Oct 12 '23 15:10 7nik

@7nik

  1. It would not need to behave that way, $prop() is not just a function either and the assigned variable becomes a signal; it all gets transformed anyway.
    Of course it might confuse people if a null value would override the default specified after ??.
  2. Right, it's either adding the type annotation to the variable, or the default value (using as). Setting the generic type parameter will not work here, though.

I was looking for a different syntax that might help express a default value and ?? seemed like the closest match that also kind of works with TS as is.

Maybe just a having a separate rune for the aliasing case is the best option if an additional argument does not work.
Always using an options argument might also be possible, but that would be a bit more verbose, i.e. something like:

let required = $prop<number>();
let withDefault = $prop({ default: 42 });
let aliasedRequired = $prop<string>({ name: 'class' });
let aliasedWithDefault = $prop({ name: 'class', default: 'flex' });

brunnerh avatar Oct 12 '23 16:10 brunnerh

The main limitation is not making a js/ts correct code but making such syntax that any third-party js/ts parser/tool will understand the source code as correctly as possible (with, unfortunately, losing the understanding of reactivity and components interaction but nothing else).

This is why runes look like a function - thus, you can define them as a global function. But you cannot alter the behaviour of operators.

7nik avatar Oct 12 '23 16:10 7nik

@brunnerh the last one probably is the best one, considering all the trade-offs and extendability. But I think specifying the type on the variable would be more natural:

let required: number = $prop();
let withDefault = $prop({ default: 42 });
let aliasedRequired: string = $prop({ name: 'class' });
let aliasedWithDefault = $prop({ name: 'class', default: 'flex' });

7nik avatar Oct 12 '23 16:10 7nik

@Not-Jayden What are your thoughts on using undefined to always make the prop required, while using null to explicitly set a non-value? Are there practical use-cases for which undefined as default value would be strictly necessary?

For me personally that fits into how I learned to think about the difference of null and undefined. If the developer wants to explicitly mark a value as non existent, they use null, while undefined means something was never there to begin with (or in this case we don't want to set anything as default value).

Jak-Ch-ll avatar Oct 12 '23 21:10 Jak-Ch-ll