Skip to content

Commit f6dfac9

Browse files
authored
feat: Bindable types (#11225)
This is a typings PR and the companion PR to sveltejs/language-tools#2336 It introduces two new types: - Binding: Marks a property as being bound (i.e. you must do bind:x) - Bindable: Marks a property as being able to be bound (i.e. you can do bind:x) Language tools then uses this generate code accordingly which then generates type errors. All the other type gymnastics are there to ensure that you don't interact with these bindable types when using mount or hydrate or ComponentProps<MyComponent>, i.e. these two types should be mostly opaque for day-to-day users. For backwards-compatibility, all properties are automatically wrapped with Bindable, which means existing type definition files will continue to work from a types perspective. Language tools opts into strict bindability by providing its own constructor definition for all generated classes in runes mode which omits the "wrap everything with bindable" behavior.
1 parent 1f04045 commit f6dfac9

File tree

8 files changed

+193
-43
lines changed

8 files changed

+193
-43
lines changed

.changeset/pink-goats-promise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"svelte": patch
3+
---
4+
5+
feat: introduce types to express bindability

packages/svelte/src/compiler/index.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,32 @@ function handle_compile_error(error, filename, source) {
104104
throw error;
105105
}
106106

107+
/**
108+
* The parse function parses a component, returning only its abstract syntax tree.
109+
*
110+
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
111+
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
112+
*
113+
* https://svelte.dev/docs/svelte-compiler#svelte-parse
114+
* @overload
115+
* @param {string} source
116+
* @param {{ filename?: string; modern: true }} options
117+
* @returns {import('#compiler').Root}
118+
*/
119+
120+
/**
121+
* The parse function parses a component, returning only its abstract syntax tree.
122+
*
123+
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
124+
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
125+
*
126+
* https://svelte.dev/docs/svelte-compiler#svelte-parse
127+
* @overload
128+
* @param {string} source
129+
* @param {{ filename?: string; modern?: false }} [options]
130+
* @returns {import('./types/legacy-nodes.js').LegacyRoot}
131+
*/
132+
107133
/**
108134
* The parse function parses a component, returning only its abstract syntax tree.
109135
*

packages/svelte/src/index.d.ts

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are).
22

3+
import './ambient.js';
4+
import type { RemoveBindable } from './internal/types.js';
5+
36
/**
47
* @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore.
58
* Use `mount` or `createRoot` instead to instantiate components.
@@ -18,13 +21,37 @@ export interface ComponentConstructorOptions<
1821
$$inline?: boolean;
1922
}
2023

21-
// Utility type for ensuring backwards compatibility on a type level: If there's a default slot, add 'children' to the props if it doesn't exist there already
22-
type PropsWithChildren<Props, Slots> = Props &
23-
(Props extends { children?: any }
24-
? {}
25-
: Slots extends { default: any }
26-
? { children?: Snippet }
27-
: {});
24+
/** Tooling for types uses this for properties are being used with `bind:` */
25+
export type Binding<T> = { 'bind:': T };
26+
/**
27+
* Tooling for types uses this for properties that may be bound to.
28+
* Only use this if you author Svelte component type definition files by hand (we recommend using `@sveltejs/package` instead).
29+
* Example:
30+
* ```ts
31+
* export class MyComponent extends SvelteComponent<{ readonly: string, bindable: Bindable<string> }> {}
32+
* ```
33+
* means you can now do `<MyComponent {readonly} bind:bindable />`
34+
*/
35+
export type Bindable<T> = T | Binding<T>;
36+
37+
type WithBindings<T> = {
38+
[Key in keyof T]: Bindable<T[Key]>;
39+
};
40+
41+
/**
42+
* Utility type for ensuring backwards compatibility on a type level:
43+
* - If there's a default slot, add 'children' to the props
44+
* - All props are bindable
45+
*/
46+
type PropsWithChildren<Props, Slots> = WithBindings<Props> &
47+
(Slots extends { default: any }
48+
? // This is unfortunate because it means "accepts no props" turns into "accepts any prop"
49+
// but the alternative is non-fixable type errors because of the way TypeScript index
50+
// signatures work (they will always take precedence and make an impossible-to-satisfy children type).
51+
Props extends Record<string, never>
52+
? any
53+
: { children?: any }
54+
: {});
2855

2956
/**
3057
* Can be used to create strongly typed Svelte components.
@@ -55,7 +82,7 @@ type PropsWithChildren<Props, Slots> = Props &
5582
* for more info.
5683
*/
5784
export class SvelteComponent<
58-
Props extends Record<string, any> = any,
85+
Props extends Record<string, any> = Record<string, any>,
5986
Events extends Record<string, any> = any,
6087
Slots extends Record<string, any> = any
6188
> {
@@ -74,7 +101,7 @@ export class SvelteComponent<
74101
* Does not exist at runtime.
75102
* ### DO NOT USE!
76103
* */
77-
$$prop_def: PropsWithChildren<Props, Slots>;
104+
$$prop_def: RemoveBindable<Props>; // Without PropsWithChildren: unnecessary, causes type bugs
78105
/**
79106
* For type checking capabilities only.
80107
* Does not exist at runtime.
@@ -119,7 +146,7 @@ export class SvelteComponent<
119146
* @deprecated Use `SvelteComponent` instead. See TODO for more information.
120147
*/
121148
export class SvelteComponentTyped<
122-
Props extends Record<string, any> = any,
149+
Props extends Record<string, any> = Record<string, any>,
123150
Events extends Record<string, any> = any,
124151
Slots extends Record<string, any> = any
125152
> extends SvelteComponent<Props, Events, Slots> {}
@@ -154,7 +181,7 @@ export type ComponentEvents<Comp extends SvelteComponent> =
154181
* ```
155182
*/
156183
export type ComponentProps<Comp extends SvelteComponent> =
157-
Comp extends SvelteComponent<infer Props> ? Props : never;
184+
Comp extends SvelteComponent<infer Props> ? RemoveBindable<Props> : never;
158185

159186
/**
160187
* Convenience type to get the type of a Svelte component. Useful for example in combination with
@@ -226,4 +253,3 @@ export interface EventDispatcher<EventMap extends Record<string, any>> {
226253
}
227254

228255
export * from './index-client.js';
229-
import './ambient.js';

packages/svelte/src/internal/client/render.js

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export function stringify(value) {
9292
* @param {{
9393
* target: Document | Element | ShadowRoot;
9494
* anchor?: Node;
95-
* props?: Props;
95+
* props?: import('../types.js').RemoveBindable<Props>;
9696
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
9797
* context?: Map<any, any>;
9898
* intro?: boolean;
@@ -114,7 +114,7 @@ export function mount(component, options) {
114114
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} component
115115
* @param {{
116116
* target: Document | Element | ShadowRoot;
117-
* props?: Props;
117+
* props?: import('../types.js').RemoveBindable<Props>;
118118
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
119119
* context?: Map<any, any>;
120120
* intro?: boolean;
@@ -181,24 +181,19 @@ export function hydrate(component, options) {
181181
}
182182

183183
/**
184-
* @template {Record<string, any>} Props
185184
* @template {Record<string, any>} Exports
186-
* @template {Record<string, any>} Events
187-
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} Component
185+
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<any>>} Component
188186
* @param {{
189187
* target: Document | Element | ShadowRoot;
190188
* anchor: Node;
191-
* props?: Props;
192-
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
189+
* props?: any;
190+
* events?: any;
193191
* context?: Map<any, any>;
194192
* intro?: boolean;
195193
* }} options
196194
* @returns {Exports}
197195
*/
198-
function _mount(
199-
Component,
200-
{ target, anchor, props = /** @type {Props} */ ({}), events, context, intro = false }
201-
) {
196+
function _mount(Component, { target, anchor, props = {}, events, context, intro = false }) {
202197
init_operations();
203198

204199
const registered_events = new Set();

packages/svelte/src/internal/client/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { Bindable, Binding } from '../../index.js';
12
import type { Store } from '#shared';
23
import { STATE_SYMBOL } from './constants.js';
34
import type { Effect, Source, Value } from './reactivity/types.js';
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,8 @@
1+
import type { Bindable } from '../index.js';
2+
13
/** Anything except a function */
24
export type NotFunction<T> = T extends Function ? never : T;
5+
6+
export type RemoveBindable<Props extends Record<string, any>> = {
7+
[Key in keyof Props]: Props[Key] extends Bindable<infer Value> ? Value : Props[Key];
8+
};

packages/svelte/tests/types/component.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
type ComponentProps,
66
type ComponentType,
77
mount,
8-
hydrate
8+
hydrate,
9+
type Bindable,
10+
type Binding,
11+
type ComponentConstructorOptions
912
} from 'svelte';
1013

1114
SvelteComponent.element === HTMLElement;
@@ -174,3 +177,53 @@ const x: typeof asLegacyComponent = createClassComponent({
174177
hydrate: true,
175178
component: NewComponent
176179
});
180+
181+
// --------------------------------------------------------------------------- bindable
182+
183+
// Test that
184+
// - everything's bindable unless the component constructor is specifically set telling otherwise (for backwards compatibility)
185+
// - when using mount etc the props are never bindable because this is language-tools only concept
186+
187+
function binding<T>(value: T): Binding<T> {
188+
return value as any;
189+
}
190+
191+
class Explicit extends SvelteComponent<{
192+
foo: string;
193+
bar: Bindable<boolean>;
194+
}> {
195+
constructor(options: ComponentConstructorOptions<{ foo: string; bar: Bindable<boolean> }>) {
196+
super(options);
197+
}
198+
}
199+
new Explicit({ target: null as any, props: { foo: 'foo', bar: binding(true) } });
200+
new Explicit({ target: null as any, props: { foo: 'foo', bar: true } });
201+
new Explicit({
202+
target: null as any,
203+
props: {
204+
// @ts-expect-error
205+
foo: binding(''),
206+
bar: true
207+
}
208+
});
209+
mount(Explicit, { target: null as any, props: { foo: 'foo', bar: true } });
210+
mount(Explicit, {
211+
target: null as any,
212+
props: {
213+
// @ts-expect-error
214+
bar: binding(true)
215+
}
216+
});
217+
218+
class Implicit extends SvelteComponent<{ foo: string; bar: boolean }> {}
219+
new Implicit({ target: null as any, props: { foo: 'foo', bar: true } });
220+
new Implicit({ target: null as any, props: { foo: binding(''), bar: binding(true) } });
221+
mount(Implicit, { target: null as any, props: { foo: 'foo', bar: true } });
222+
mount(Implicit, {
223+
target: null as any,
224+
props: {
225+
foo: 'foo',
226+
// @ts-expect-error
227+
bar: binding(true)
228+
}
229+
});

0 commit comments

Comments
 (0)