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.
pull/11307/head
Simon H 2 months ago committed by GitHub
parent 1f04045052
commit f6dfac985c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
"svelte": patch
---
feat: introduce types to express bindability

@ -104,6 +104,32 @@ function handle_compile_error(error, filename, source) {
throw error;
}
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* https://svelte.dev/docs/svelte-compiler#svelte-parse
* @overload
* @param {string} source
* @param {{ filename?: string; modern: true }} options
* @returns {import('#compiler').Root}
*/
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* https://svelte.dev/docs/svelte-compiler#svelte-parse
* @overload
* @param {string} source
* @param {{ filename?: string; modern?: false }} [options]
* @returns {import('./types/legacy-nodes.js').LegacyRoot}
*/
/**
* The parse function parses a component, returning only its abstract syntax tree.
*

@ -1,5 +1,8 @@
// This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are).
import './ambient.js';
import type { RemoveBindable } from './internal/types.js';
/**
* @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore.
* Use `mount` or `createRoot` instead to instantiate components.
@ -18,13 +21,37 @@ export interface ComponentConstructorOptions<
$$inline?: boolean;
}
// 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
type PropsWithChildren<Props, Slots> = Props &
(Props extends { children?: any }
? {}
: Slots extends { default: any }
? { children?: Snippet }
: {});
/** Tooling for types uses this for properties are being used with `bind:` */
export type Binding<T> = { 'bind:': T };
/**
* Tooling for types uses this for properties that may be bound to.
* Only use this if you author Svelte component type definition files by hand (we recommend using `@sveltejs/package` instead).
* Example:
* ```ts
* export class MyComponent extends SvelteComponent<{ readonly: string, bindable: Bindable<string> }> {}
* ```
* means you can now do `<MyComponent {readonly} bind:bindable />`
*/
export type Bindable<T> = T | Binding<T>;
type WithBindings<T> = {
[Key in keyof T]: Bindable<T[Key]>;
};
/**
* Utility type for ensuring backwards compatibility on a type level:
* - If there's a default slot, add 'children' to the props
* - All props are bindable
*/
type PropsWithChildren<Props, Slots> = WithBindings<Props> &
(Slots extends { default: any }
? // This is unfortunate because it means "accepts no props" turns into "accepts any prop"
// but the alternative is non-fixable type errors because of the way TypeScript index
// signatures work (they will always take precedence and make an impossible-to-satisfy children type).
Props extends Record<string, never>
? any
: { children?: any }
: {});
/**
* Can be used to create strongly typed Svelte components.
@ -55,7 +82,7 @@ type PropsWithChildren<Props, Slots> = Props &
* for more info.
*/
export class SvelteComponent<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> {
@ -74,7 +101,7 @@ export class SvelteComponent<
* Does not exist at runtime.
* ### DO NOT USE!
* */
$$prop_def: PropsWithChildren<Props, Slots>;
$$prop_def: RemoveBindable<Props>; // Without PropsWithChildren: unnecessary, causes type bugs
/**
* For type checking capabilities only.
* Does not exist at runtime.
@ -119,7 +146,7 @@ export class SvelteComponent<
* @deprecated Use `SvelteComponent` instead. See TODO for more information.
*/
export class SvelteComponentTyped<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends SvelteComponent<Props, Events, Slots> {}
@ -154,7 +181,7 @@ export type ComponentEvents<Comp extends SvelteComponent> =
* ```
*/
export type ComponentProps<Comp extends SvelteComponent> =
Comp extends SvelteComponent<infer Props> ? Props : never;
Comp extends SvelteComponent<infer Props> ? RemoveBindable<Props> : never;
/**
* 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>> {
}
export * from './index-client.js';
import './ambient.js';

@ -92,7 +92,7 @@ export function stringify(value) {
* @param {{
* target: Document | Element | ShadowRoot;
* anchor?: Node;
* props?: Props;
* props?: import('../types.js').RemoveBindable<Props>;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* context?: Map<any, any>;
* intro?: boolean;
@ -114,7 +114,7 @@ export function mount(component, options) {
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} component
* @param {{
* target: Document | Element | ShadowRoot;
* props?: Props;
* props?: import('../types.js').RemoveBindable<Props>;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* context?: Map<any, any>;
* intro?: boolean;
@ -181,24 +181,19 @@ export function hydrate(component, options) {
}
/**
* @template {Record<string, any>} Props
* @template {Record<string, any>} Exports
* @template {Record<string, any>} Events
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<Props, Events>>} Component
* @param {import('../../index.js').ComponentType<import('../../index.js').SvelteComponent<any>>} Component
* @param {{
* target: Document | Element | ShadowRoot;
* anchor: Node;
* props?: Props;
* events?: { [Property in keyof Events]: (e: Events[Property]) => any };
* props?: any;
* events?: any;
* context?: Map<any, any>;
* intro?: boolean;
* }} options
* @returns {Exports}
*/
function _mount(
Component,
{ target, anchor, props = /** @type {Props} */ ({}), events, context, intro = false }
) {
function _mount(Component, { target, anchor, props = {}, events, context, intro = false }) {
init_operations();
const registered_events = new Set();

@ -1,3 +1,4 @@
import type { Bindable, Binding } from '../../index.js';
import type { Store } from '#shared';
import { STATE_SYMBOL } from './constants.js';
import type { Effect, Source, Value } from './reactivity/types.js';

@ -1,2 +1,8 @@
import type { Bindable } from '../index.js';
/** Anything except a function */
export type NotFunction<T> = T extends Function ? never : T;
export type RemoveBindable<Props extends Record<string, any>> = {
[Key in keyof Props]: Props[Key] extends Bindable<infer Value> ? Value : Props[Key];
};

@ -5,7 +5,10 @@ import {
type ComponentProps,
type ComponentType,
mount,
hydrate
hydrate,
type Bindable,
type Binding,
type ComponentConstructorOptions
} from 'svelte';
SvelteComponent.element === HTMLElement;
@ -174,3 +177,53 @@ const x: typeof asLegacyComponent = createClassComponent({
hydrate: true,
component: NewComponent
});
// --------------------------------------------------------------------------- bindable
// Test that
// - everything's bindable unless the component constructor is specifically set telling otherwise (for backwards compatibility)
// - when using mount etc the props are never bindable because this is language-tools only concept
function binding<T>(value: T): Binding<T> {
return value as any;
}
class Explicit extends SvelteComponent<{
foo: string;
bar: Bindable<boolean>;
}> {
constructor(options: ComponentConstructorOptions<{ foo: string; bar: Bindable<boolean> }>) {
super(options);
}
}
new Explicit({ target: null as any, props: { foo: 'foo', bar: binding(true) } });
new Explicit({ target: null as any, props: { foo: 'foo', bar: true } });
new Explicit({
target: null as any,
props: {
// @ts-expect-error
foo: binding(''),
bar: true
}
});
mount(Explicit, { target: null as any, props: { foo: 'foo', bar: true } });
mount(Explicit, {
target: null as any,
props: {
// @ts-expect-error
bar: binding(true)
}
});
class Implicit extends SvelteComponent<{ foo: string; bar: boolean }> {}
new Implicit({ target: null as any, props: { foo: 'foo', bar: true } });
new Implicit({ target: null as any, props: { foo: binding(''), bar: binding(true) } });
mount(Implicit, { target: null as any, props: { foo: 'foo', bar: true } });
mount(Implicit, {
target: null as any,
props: {
foo: 'foo',
// @ts-expect-error
bar: binding(true)
}
});

@ -1,6 +1,4 @@
declare module 'svelte' {
// This should contain all the public interfaces (not all of them are actually importable, check current Svelte for which ones are).
/**
* @deprecated Svelte components were classes in Svelte 4. In Svelte 5, thy are not anymore.
* Use `mount` or `createRoot` instead to instantiate components.
@ -19,13 +17,37 @@ declare module 'svelte' {
$$inline?: boolean;
}
// 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
type PropsWithChildren<Props, Slots> = Props &
(Props extends { children?: any }
? {}
: Slots extends { default: any }
? { children?: Snippet }
: {});
/** Tooling for types uses this for properties are being used with `bind:` */
export type Binding<T> = { 'bind:': T };
/**
* Tooling for types uses this for properties that may be bound to.
* Only use this if you author Svelte component type definition files by hand (we recommend using `@sveltejs/package` instead).
* Example:
* ```ts
* export class MyComponent extends SvelteComponent<{ readonly: string, bindable: Bindable<string> }> {}
* ```
* means you can now do `<MyComponent {readonly} bind:bindable />`
*/
export type Bindable<T> = T | Binding<T>;
type WithBindings<T> = {
[Key in keyof T]: Bindable<T[Key]>;
};
/**
* Utility type for ensuring backwards compatibility on a type level:
* - If there's a default slot, add 'children' to the props
* - All props are bindable
*/
type PropsWithChildren<Props, Slots> = WithBindings<Props> &
(Slots extends { default: any }
? // This is unfortunate because it means "accepts no props" turns into "accepts any prop"
// but the alternative is non-fixable type errors because of the way TypeScript index
// signatures work (they will always take precedence and make an impossible-to-satisfy children type).
Props extends Record<string, never>
? any
: { children?: any }
: {});
/**
* Can be used to create strongly typed Svelte components.
@ -56,7 +78,7 @@ declare module 'svelte' {
* for more info.
*/
export class SvelteComponent<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> {
@ -75,7 +97,7 @@ declare module 'svelte' {
* Does not exist at runtime.
* ### DO NOT USE!
* */
$$prop_def: PropsWithChildren<Props, Slots>;
$$prop_def: RemoveBindable<Props>; // Without PropsWithChildren: unnecessary, causes type bugs
/**
* For type checking capabilities only.
* Does not exist at runtime.
@ -120,7 +142,7 @@ declare module 'svelte' {
* @deprecated Use `SvelteComponent` instead. See TODO for more information.
*/
export class SvelteComponentTyped<
Props extends Record<string, any> = any,
Props extends Record<string, any> = Record<string, any>,
Events extends Record<string, any> = any,
Slots extends Record<string, any> = any
> extends SvelteComponent<Props, Events, Slots> {}
@ -155,7 +177,7 @@ declare module 'svelte' {
* ```
*/
export type ComponentProps<Comp extends SvelteComponent> =
Comp extends SvelteComponent<infer Props> ? Props : never;
Comp extends SvelteComponent<infer Props> ? RemoveBindable<Props> : never;
/**
* Convenience type to get the type of a Svelte component. Useful for example in combination with
@ -225,6 +247,12 @@ declare module 'svelte' {
: [type: Type, parameter: EventMap[Type], options?: DispatchOptions]
): boolean;
}
/** Anything except a function */
type NotFunction<T> = T extends Function ? never : T;
type RemoveBindable<Props extends Record<string, any>> = {
[Key in keyof Props]: Props[Key] extends Bindable<infer Value> ? Value : Props[Key];
};
/**
* The `onMount` function schedules a callback to run as soon as the component has been mounted to the DOM.
* It must be called during the component's initialisation (but doesn't need to live *inside* the component;
@ -295,8 +323,6 @@ declare module 'svelte' {
* Synchronously flushes any pending state changes and those that result from it.
* */
export function flushSync(fn?: (() => void) | undefined): void;
/** Anything except a function */
type NotFunction<T> = T extends Function ? never : T;
/**
* Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component
*
@ -304,7 +330,7 @@ declare module 'svelte' {
export function mount<Props extends Record<string, any>, Exports extends Record<string, any>, Events extends Record<string, any>>(component: ComponentType<SvelteComponent<Props, Events, any>>, options: {
target: Document | Element | ShadowRoot;
anchor?: Node | undefined;
props?: Props | undefined;
props?: RemoveBindable<Props> | undefined;
events?: { [Property in keyof Events]: (e: Events[Property]) => any; } | undefined;
context?: Map<any, any> | undefined;
intro?: boolean | undefined;
@ -315,7 +341,7 @@ declare module 'svelte' {
* */
export function hydrate<Props extends Record<string, any>, Exports extends Record<string, any>, Events extends Record<string, any>>(component: ComponentType<SvelteComponent<Props, Events, any>>, options: {
target: Document | Element | ShadowRoot;
props?: Props | undefined;
props?: RemoveBindable<Props> | undefined;
events?: { [Property in keyof Events]: (e: Events[Property]) => any; } | undefined;
context?: Map<any, any> | undefined;
intro?: boolean | undefined;
@ -492,6 +518,18 @@ declare module 'svelte/compiler' {
* @param source The component source code
* */
export function compileModule(source: string, options: ModuleCompileOptions): CompileResult;
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
* The `modern` option (`false` by default in Svelte 5) makes the parser return a modern AST instead of the legacy AST.
* `modern` will become `true` by default in Svelte 6, and the option will be removed in Svelte 7.
*
* https://svelte.dev/docs/svelte-compiler#svelte-parse
* */
export function parse(source: string, options: {
filename?: string;
modern: true;
}): Root;
/**
* The parse function parses a component, returning only its abstract syntax tree.
*
@ -502,8 +540,8 @@ declare module 'svelte/compiler' {
* */
export function parse(source: string, options?: {
filename?: string | undefined;
modern?: boolean | undefined;
} | undefined): Root | LegacyRoot;
modern?: false | undefined;
} | undefined): LegacyRoot;
/**
* @deprecated Replace this with `import { walk } from 'estree-walker'`
* */

Loading…
Cancel
Save