Skip to content

Commit 8c080cf

Browse files
authored
feat: better type checking for bindings in Svelte 5 (#2477)
#1392 This adds enhanced type checking for bindings in Svelte 5: We're not only passing the variable in, we're also assigning the value of the component property back to the variable. That way, we can catch type errors like the child binding having a wider type as the input we give it. It's done for Svelte 5 only because it's a) easier there b) doesn't break as much code (people who upgrade can then also upgrade the types, or use type assertions in the template, which is only possible in Svelte 5). There's one limitation: Because of how the transformation works, we cannot infer generics. In other words, we will not catch type errors for bindings that rely on a generic type. The combination of generics + bindings is probably rare enough that this is okay, and we can revisit this later to try to find a way to make it work, if it comes up. Also note that this does not affect DOM element bindings like <input bind:value={...} />, this is only about component bindings.
1 parent ec5fef4 commit 8c080cf

File tree

13 files changed

+116
-14
lines changed

13 files changed

+116
-14
lines changed

packages/language-server/src/plugins/typescript/features/RenameProvider.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,8 +463,16 @@ export class RenameProviderImpl implements RenameProvider {
463463
const mappedLocations = await Promise.all(
464464
renameLocations.map(async (loc) => {
465465
const snapshot = await snapshots.retrieve(loc.fileName);
466+
const text = snapshot.getFullText();
467+
const end = loc.textSpan.start + loc.textSpan.length;
466468

467-
if (!isTextSpanInGeneratedCode(snapshot.getFullText(), loc.textSpan)) {
469+
if (
470+
!(snapshot instanceof SvelteDocumentSnapshot) ||
471+
(!isTextSpanInGeneratedCode(text, loc.textSpan) &&
472+
// prevent generated code for bindings from being renamed
473+
// (it's not inside a generate comment because diagnostics should show up)
474+
text.slice(end + 3, end + 27) !== '__sveltets_binding_value')
475+
) {
468476
return {
469477
...loc,
470478
range: this.mapRangeToOriginal(snapshot, loc.textSpan),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<script lang="ts">
2+
export let legacy1: string = '';
3+
export let legacy2: number | string = '';
4+
</script>
5+
6+
{legacy1}
7+
{legacy2}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script lang="ts">
2+
let { runes1 = $bindable(), runes2 = $bindable() }: { runes1: string, runes2: string | number } = $props();
3+
</script>
4+
5+
{runes1}
6+
{runes2}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script lang="ts" generics="T">
2+
let { foo, bar = $bindable() }: { foo: T, bar?: T } = $props();
3+
</script>
4+
5+
{foo}
6+
{bar}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[
2+
{
3+
"code": 2322,
4+
"message": "Type 'string | number' is not assignable to type 'string'.\n Type 'number' is not assignable to type 'string'.",
5+
"range": {
6+
"end": {
7+
"character": 28,
8+
"line": 17
9+
},
10+
"start": {
11+
"character": 28,
12+
"line": 17
13+
}
14+
},
15+
"severity": 1,
16+
"source": "ts",
17+
"tags": []
18+
},
19+
{
20+
"code": 2322,
21+
"message": "Type 'string | number' is not assignable to type 'string'.\n Type 'number' is not assignable to type 'string'.",
22+
"range": {
23+
"end": {
24+
"character": 45,
25+
"line": 18
26+
},
27+
"start": {
28+
"character": 45,
29+
"line": 18
30+
}
31+
},
32+
"severity": 1,
33+
"source": "ts",
34+
"tags": []
35+
}
36+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<script lang="ts">
2+
import Legacy from './Legacy.svelte';
3+
import Runes from './Runes.svelte';
4+
import RunesGeneric from './RunesGeneric.svelte';
5+
6+
let legacy = '';
7+
let runes = '';
8+
let foo: string | number;
9+
</script>
10+
11+
<!-- ok -->
12+
<Legacy bind:legacy1={legacy} />
13+
<Runes bind:runes1={runes} bind:runes2={(runes as any)} />
14+
<!-- bail on generics -->
15+
<RunesGeneric {foo} bind:bar={runes} />
16+
17+
<!-- error in Svelte 5 -->
18+
<Legacy bind:legacy2={legacy} />
19+
<Runes bind:runes1={runes} bind:runes2={runes} />

packages/svelte2tsx/repl/index.svelte

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
<script>
2-
export let value;
1+
<script lang="ts">
2+
import MyComponent from './ComponentB.svelte'
3+
let value: Date
4+
$: console.log('value:', value)
35
</script>
4-
5-
{#if value}
6-
<input bind:value on:change />
7-
{/if}
6+
7+
<MyComponent bind:value />

packages/svelte2tsx/src/htmlxtojsx_v2/nodes/Binding.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,12 @@ export function handleBinding(
131131
if (isSvelte5Plus && element instanceof InlineComponent) {
132132
// To check if property is actually bindable
133133
element.appendToStartEnd([`${element.name}.$$bindings = '${attr.name}';`]);
134+
// To check if the binding is also assigned to the variable (only works when there's no assertion, we can't transform that)
135+
if (!isTypescriptNode(attr.expression)) {
136+
element.appendToStartEnd([
137+
`${expressionStr} = __sveltets_binding_value(${element.originalName}, '${attr.name}');`
138+
]);
139+
}
134140
}
135141

136142
if (element instanceof Element) {

packages/svelte2tsx/src/htmlxtojsx_v2/nodes/InlineComponent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export class InlineComponent {
3939
private startTagEnd: number;
4040
private isSelfclosing: boolean;
4141
public child?: any;
42+
public originalName = this.node.name;
4243

4344
// Add const $$xxx = ... only if the variable name is actually used
4445
// in order to prevent "$$xxx is defined but never used" TS hints

packages/svelte2tsx/svelte-shims-v4.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,16 @@ declare function __sveltets_2_isomorphic_component<
254254
declare function __sveltets_2_isomorphic_component_slots<
255255
Props extends Record<string, any>, Events extends Record<string, any>, Slots extends Record<string, any>, Exports extends Record<string, any>, Bindings extends string
256256
>(klass: {props: Props, events: Events, slots: Slots, exports?: Exports, bindings?: Bindings }): __sveltets_2_IsomorphicComponent<__sveltets_2_PropsWithChildren<Props, Slots>, Events, Slots, Exports, Bindings>;
257+
258+
type __sveltets_NonUndefined<T> = T extends undefined ? never : T;
259+
260+
declare function __sveltets_binding_value<
261+
// @ts-ignore this is only used for Svelte 5, which knows about the Component type
262+
Comp extends typeof import('svelte').Component<any>,
263+
Key extends string
264+
>(comp: Comp, key: Key): Key extends keyof import('svelte').ComponentProps<Comp> ?
265+
// bail on unknown because it hints at a generic type which we can't properly resolve here
266+
// remove undefined because optional properties have it, and would result in false positives
267+
unknown extends import('svelte').ComponentProps<Comp>[Key] ? any : __sveltets_NonUndefined<import('svelte').ComponentProps<Comp>[Key]> : any;
268+
// Overload to ensure typings that only use old SvelteComponent class or something invalid are gracefully handled
269+
declare function __sveltets_binding_value(comp: any, key: string): any

packages/svelte2tsx/test/htmlx2jsx/samples/binding-bare/expected-svelte5.js

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/svelte2tsx/test/htmlx2jsx/samples/binding/expected-svelte5.js

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/svelte2tsx/test/htmlx2jsx/samples/editing-binding/expected-svelte5.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)