Skip to content

Commit

Permalink
feat: Bindable types (#11225)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
dummdidumm authored Apr 24, 2024
1 parent 1f04045 commit f6dfac9
Show file tree
Hide file tree
Showing 8 changed files with 193 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/pink-goats-promise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"svelte": patch
---

feat: introduce types to express bindability
26 changes: 26 additions & 0 deletions packages/svelte/src/compiler/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
50 changes: 38 additions & 12 deletions packages/svelte/src/index.d.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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
> {
Expand All @@ -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.
Expand Down Expand Up @@ -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> {}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -226,4 +253,3 @@ export interface EventDispatcher<EventMap extends Record<string, any>> {
}

export * from './index-client.js';
import './ambient.js';
17 changes: 6 additions & 11 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/internal/client/types.d.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
6 changes: 6 additions & 0 deletions packages/svelte/src/internal/types.d.ts
Original file line number Diff line number Diff line change
@@ -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];
};
55 changes: 54 additions & 1 deletion packages/svelte/tests/types/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import {
type ComponentProps,
type ComponentType,
mount,
hydrate
hydrate,
type Bindable,
type Binding,
type ComponentConstructorOptions
} from 'svelte';

SvelteComponent.element === HTMLElement;
Expand Down Expand Up @@ -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)
}
});
Loading

0 comments on commit f6dfac9

Please sign in to comment.