Add a (singular) `$prop` rune
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
$propsrune, which suffers from the aforementioned problems.
Importance
nice to have
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');
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.
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.
@Snailedlt That should further simplify to:
let {
foo = 'default',
bar: number,
buzz = 123,
} = $props();
@Snailedlt That syntax is already reserved by JavaScript for specifying aliases for the destructured properties. It can't be used for specifying types.
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.
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 But the prop names are still being repeated, which is the main problem.
@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 Again, invalid syntax.
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 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.
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);
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; .. }>())
This can be hidden in $props.single(). There is $effect.pre(), so it is similar....
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.
exporting
classis 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.
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.
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;orexport 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$propsrune 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.
Agree with having $aliasedProp or $prop.alias but highly disagree on the curry syntax as it looks busier with them.
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
$propand 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
$aliasedPropcould 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.
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
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.)
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 typenumberand thus noasfunction, 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 thethisreturn 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
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
definePropmacro: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?
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);
-
??will replace bothnullandundefinedwith the default value, while the default value at restructuring replaces onlyundefined. - The
asoperator does type casting with either type narrowing or extending and shouldn't be used at variable initialization unless you really want it. Example.
@7nik
- 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 anullvalue would override the default specified after??. - 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' });
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.
@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' });
@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).