Configuration

TypeScript

Edit this page

TypeScript is a superset of JavaScript that enhances code reliability and predictability through the introduction of static types. While JavaScript code can be directly used in TypeScript, the added type annotations in TypeScript provide clearer code structure and documentation, making it more accessible for developers.

By leveraging standard JSX, a syntax extension to JavaScript, Solid facilitates seamless TypeScript interpretation. Moreover, Solid has built-in types for the API that heighten accuracy.

For developers eager to get started, we offer TypeScript templates on GitHub.


Configuring TypeScript

When integrating TypeScript with the Solid JSX compiler, there are some settings to make for a seamless interaction:

  1. "jsx": "preserve" in the tsconfig.json retains the original JSX form. This is because Solid's JSX transformation is incompatible with TypeScript's JSX transformation.
  2. "jsxImportSource": "solid-js" designates SolidJS as the source of JSX types.

For a basic setup, your tsconfig.json should resemble:

{
"compilerOptions": {
"jsx": "preserve",
"jsxImportSource": "solid-js"
}
}

For projects with diverse JSX sources, such as a blend of React and Solid, some flexibility exists. While it's possible to set a default jsxImportSource in the tsconfig.json, which will correspond with the majority of your files, TypeScript also allows file-level overrides. Using specific pragmas within .tsx files facilitates this:

/** @jsxImportSource solid-js */

or, if using React:

/** @jsxImportSource react */

Opting for the React JSX pragma means having React and its associated dependencies fully integrated into the project. Additionally, it makes sure the project's architecture is primed for React JSX file handling, which is vital.


Migrating from JavaScript to TypeScript

Transitioning from JavaScript to TypeScript in a Solid project offers the benefits of static typing. To migrate to Typescript:

  1. Install TypeScript into your project.
npm install --save-dev typescript
  1. Run npx tsc --init to generate a tsconfig.json file.

  2. Update the contents of the tsconfig.json to match Solid's configuration:

{
"compilerOptions": {
"strict": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"jsx": "preserve",
"jsxImportSource": "solid-js",
"types": ["vite/client"],
"noEmit": true,
"isolatedModules": true
}
}
  1. Create a TypeScript or .tsx file to test the setup.
import { type Component } from "solid-js"
function MyTsComponent(): JSX.Element {
return (
<div>
<h1>This is a TypeScript component</h1>
</div>
)
}
export default MyTsComponent

If using an existing JavaScript component, import the TypeScript component:

import MyTsComponent from "./MyTsComponent";
function MyJsComponent() {
return (
<>
{/* ... */}
<MyTsComponent />
</>
);
}

API types

Solid is written in TypeScript, meaning everything is typed out of the box.

The Reference Tab in the sidebar provides the API Documentation that details the types of API calls. In addition, there are several helpful definitions to make it easier for specifying explicit types.

Signals

Using createSignal<T>, a signal's type can be defined as T.

const [count, setCount] = createSignal<number>();

Here, createSignal has the return type Signal<number | undefined>, which corresponds to the type passed into it, and undefined as it was uninitialized. This resolves to a getter-setter tuple, both of which are generically typed:

import type { Signal, Accessor, Setter } from "solid-js";
type Signal<T> = [get: Accessor<T>, set: Setter<T>];

In Solid, a signal's getter, like count, is essentially a function that returns a specific type. In this case, the type is Accessor<number | undefined>, which translates to a function () => number | undefined. Since the signal was not initialized, its initial state is undefined, therefore undefined is included in its type.

The corresponding setter, setCount, has a more complex type:

Setter<number | undefined>.

Essentially, this type means that the function can accept either a direct number or another function as its input. If provided with a function, that function can take the signal's previous value as its parameter and return a new value. Both the initial and resulting values can be a number or undefined. Importantly, calling setCount without any arguments will reset the signal's value to undefined.

When using the function form of the setter, the signal's current value will always be passed to the callback as the sole argument. Additionally, the return type of the setter will align with the type of value passed into it, echoing the behavior expected from a typical assignment operation.

If a signal is intended to store functions, the setter won't directly accept new functions as values. This is because it can not distinguish whether the function should be executed to yield the actual value or to store it as-is. In these situations, using the callback form of the setter is recommended:

setSignal(() => value);

Default values

By providing default values when createSignal is called, the need for explicit type specification is avoided and the possibility of the | undefined type is eliminated. This leverages type inference to determine the type automatically:

const [count, setCount] = createSignal(0);
const [name, setName] = createSignal("");

In this example, TypeScript understands the types as number and string. This means that count and name directly receive the types Accessor<number> and Accessor<string>, respectively, without the | undefined tag.

Context

Just as signals use createSignal<T>, context uses createContext<T>, which is parameterized by the type T of the context's value:

type Data = { count: number; name: string };

When invoking useContext(dataContext), the type contained within the context is returned. For example, if the context is Context<Data | undefined>, then with using useContext a result of type Data | undefined will return. The | undefined signifies that the context may not be defined in the component's ancestor hierarchy.

dataContext will be understood as Context<Data | undefined> by Solid. Calling useContext(dataContext) mirrors this type, returning Data | undefined. The | undefined arises when the context's value will be used but cannot be determined.

Much like default values in signals, | undefined can be avoided in the type by giving a default value that will be returned if no value is assigned by a context provider:

const dataContext = createContext({ count: 0, name: "" });

By providing a default value, TypeScript determines that dataContext is Context<{ count: number, name: string }>. This is equivalent to Context<Data> but without | undefined.

A common approach to this is forming a factory function to generate a context's value. By using TypeScript's ReturnType, you can use the return type of this function to type this context:

export const makeCountNameContext = (initialCount = 0, initialName = "") => {
const [count, setCount] = createSignal(initialCount);
const [name, setName] = createSignal(initialName);
return [
{ count, name },
{ setCount, setName },
] as const;
};
type CountNameContextType = ReturnType<typeof makeCountNameContext>;
export const CountNameContext = createContext<CountNameContextType>();

CountNameContextType will correspond to the result of makeCountNameContext:

[
{ count: Accessor<number>, name: Accessor<string> },
{ setCount: Setter<number>, setName: Setter<string> },
];

To retrieve the context, use useCountNameContext, which has a type signature of () => CountNameContextType | undefined.

In scenarios where undefined needs to be avoided as a possible type, assert that the context will always be present. Additionally, throwing a readable error may be preferable to non-null asserting:

export const useCountNameContext = () => {
const countName = useContext(CountNameContext);
if (!countName) {
throw new Error(
"useCountNameContext should be called inside its ContextProvider"
);
}
return countName;
};

Note: While supplying a default value to createContext can make the context perpetually defined, this approach may not always be advisable. Depending on the use case, it could lead to silent failures, which may be less preferable.

Components

The basics

By default, components in Solid use the generic Component<P> type, where P represents the props' type that is an object.

import type { Component } from "solid-js";
const MyComponent: Component<MyProps> = (props) => {
...
}

A JSX.Element denotes anything renderable by Solid, which could be a DOM node, array of JSX elements, or functions yielding JSX.Element.

Trying to pass unnecessary props or children will result in type errors:

// in counter.tsx
const Counter: Component = () => {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount((prev) => prev + 1)}>{count()}</button>
);
};
// in app.tsx
<Counter />; // ✔️
<Counter initial={5} />; // ❌: No 'initial' prop defined
<Counter>hi</Counter>; // ❌: Children aren't expected

Components with props

For components that require the use of props, they can be typed using generics:

const InitCounter: Component<{ initial: number }> = (props) => {
const [count, setCount] = createSignal(props.initial);
return (
<button onClick={() => setCount((prev) => prev + 1)}>{count()}</button>
);
};
<InitCounter initial={5} />;

Components with children

Often, components may need to accept child elements. For this, Solid provides ParentComponent, which includes children? as an optional prop. If defining a component with the function keyword, ParentProps can be used as a helper for the props:

import { ParentComponent } from "solid-js";
const CustomCounter: ParentComponent = (props) => {
const [count, setCount] = createSignal(0);
return (
<button onClick={() => setCount((prev) => prev + 1)}>
{count()}
{props.children}
</button>
);
};

In this example, props is inferred to be of the type {children?: JSX.Element }, streamlining the process of defining components that can accept children.

Special component types

Solid offers subtypes for components dealing uniquely with children:

  • VoidComponent: When a component should not accept children.
  • FlowComponent: Designed for components like <Show> or <For>, typically requiring children and, occasionally, specific children types.

These types make sure that the children fit the required type, maintaining consistent component behaviour.

Components without the Component types

Using the Component types is a matter of preference over a strict requirement. Any function that takes props and returns a JSX.Element qualifies as a valid component:

// arrow function
const MyComponent = (props: MyProps): JSX.Element => { ... }
// function declaration
function MyComponent(props: MyProps): JSX.Element { ... }
// component which takes no props
function MyComponent(): JSX.Element { ... }

It is worth noting that the Component types cannot be used to create generic components. Instead, the generics will have to be typed explicitly:

// For arrow functions, the syntax <T> by itself is invalid in TSX because it could be confused with JSX.
const MyGenericComponent = <T extends unknown>(
props: MyProps<T>
): JSX.Element => {
/* ... */
};
// Using a function declaration for a generic component
function MyGenericComponent<T>(props: MyProps<T>): JSX.Element {
/* ... */
}

Event handling

Basics

In Solid, the type for event handlers is specified as JSX.EventHandler<TElement, TEvent>. Here, TElement refers to the type of the element the event is linked to. TEvent will indicate the type of the event itself which can serve as an alternative to (event: TEvent) => void in the code. This approach guarantees accurate typing for currentTarget and target within the event object while also eliminating the need for inline event handlers.

import type { JSX } from "solid-js"
// Defining an event handler using the `EventHandler` type:
const onInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (event) => {
console.log("Input changed to", event.currentTarget.value)
}
// Then attach handler to an input element:
;<input onInput={onInput} />

Inline handlers

Defining an event handler inline within a JSX attribute automatically provides type inference and checking, eliminating the need for additional typing efforts:

<input
onInput={(event) => {
console.log("Input changed to", event.currentTarget.value);
}}
/>

currentTarget and target

In the context of event delegation, the difference between currentTarget and target is important:

  • currentTarget: Represents the DOM element to which the event handler is attached.
  • target: Any DOM element within the hierarchy of currentTarget that has initiated the event.

In the type signature JSX.EventHandler<T, E>, currentTarget will consistently have the type T. However, the type of target could be more generic, potentially any DOM element. For specific events like Input and Focus that are directly associated with input elements, the target will have the type HTMLInputElement.

ref attribute

Basics

In an environment without TypeScript, using the ref attribute in Solid ensures that the corresponding DOM element is assigned to the variable after it is rendered:

let divRef;
console.log(divRef); // Outputs: undefined
onMount(() => {
console.log(divRef); // Outputs: <div> element
});
return <div ref={divRef} />;

In a TypeScript environment, particularly with strict null checks enabled, typing these variables can be a challenge.

A safe approach in TypeScript is to acknowledge that divRef may initially be undefined and to implement checks when accessing it:

let divRef: HTMLDivElement | undefined
// This would be flagged as an error during compilation
divRef.focus()
onMount(() => {
if (!divRef) return
divRef.focus()
})
return <div ref={divRef}>...</div>

Within the scope of the onMount function, which runs after rendering, you can use a non-null assertion (indicated by the exclamation mark !):

onMount(() => {
divRef!.focus();
});

Another approach is to bypass null during the assignment phase and then apply a definite assignment assertion within the ref attribute:

let divRef: HTMLDivElement
// Compilation error as expected
divRef.focus()
onMount(() => {
divRef.focus()
})
return <div ref={divRef!}>...</div>

In this case, using divRef! within the ref attribute signals to TypeScript that divRef will receive an assignment after this stage, which is more in line with how Solid works.

Finally, a riskier approach involves using the definite assignment assertion right at the point of variable initialization. While this method bypasses TypeScript's assignment checks for that particular variable, it offers a quick but less secure workaround that could lead to runtime errors.

let divRef!: HTMLDivElement;
// Permitted by TypeScript but will throw an error at runtime:
// divRef.focus();
onMount(() => {
divRef.focus();
});

Control flow-based narrowing

Control flow-based narrowing involves refining the type of a value by using control flow statements.

Consider the following:

const user: User | undefined = maybeUser();
return <div>{user && user.name}</div>;

In Solid, however, accessors cannot be narrowed in a similar way:

const [user, setUser] = createSignal<User>();
return <div>{user() && user().name}</div>;
// ^ Object may be 'undefined'.
// Using `<Show>`:
return (
<div>
<Show when={user()}>
{user().name /* Object is possibly 'undefined' */}
</Show>
</div>
);

In this case, using optional chaining serves as an good alternative:

return <div>{user()?.name}</div>;
// Using `<Show>`:
return (
<div>
<Show when={user()}>{(nonNullishUser) => nonNullishUser().name}</Show>
</div>
);

This approach is similar using the keyed option, but offers an accessor to prevent the recreation of children each time the when value changes.

return (
<div>
<Show keyed when={user()}>
{(nonNullishUser) => nonNullishUser.name}
</Show>
</div>
);

Note that optional chaining may not always be possible. For instance, when a UserPanel component exclusively requires a User object:

return <UserPanel user={user()} />;
// ^ Type 'undefined' is not assignable to type 'User'.

If possible, consider refactoring UserPanel to accept undefined. This minimizes the changes required when user goes from undefined to User.

Otherwise, using Show's callback form works:

return (
<Show when={user()}>
{(nonNullishUser) => <UserPanel user={nonNullishUser()} />}
</Show>
);

Casting can also be a solution so long as the assumption is valid:

return <div>{user() && (user() as User).name}</div>;

It's worth noting that runtime type errors may arise from doing this. This may happen when passing a type-cast value to a component, which discards information that may be nullish followed by accessing it asynchronously, such as in an event handler or timeout, or in onCleanup.

<Show> only excludes null, undefined, and false from when when using the callback form. If the types in a union need to be differentiated, a memo or computed signal can work as an alternative solution:

type User = Admin | OtherUser;
const admin = createMemo(() => {
const u = user();
return u && u.type === "admin" ? u : undefined;
});
return <Show when={admin()}>{(a) => <AdminPanel user={a()} />}</Show>;

The following alternative also works when using Show:

<Show
when={(() => {
const u = user();
return u && u.type === "admin" ? u : undefined;
})()}
>
{(admin) => <AdminPanel user={admin()} />}
</Show>

Advanced JSX attributes and directives

Custom event handlers

To handle custom events in Solid, you can use the attributes on:___ and oncapture:___. Typing these events requires an extension of Solid's JSX namespace.

class NameEvent extends CustomEvent {
type: "Name";
detail: { name: string };
constructor(name: string) {
super("Name", { detail: { name } });
}
}
declare module "solid-js" {
namespace JSX {
interface CustomEvents {
Name: NameEvent; // Matches `on:Name`
}
interface CustomCaptureEvents {
Name: NameEvent; // Matches `oncapture:Name`
}
}
}
// Usage
<div on:Name={(event) => console.log("name is", event.detail.name)} />;

Forcing properties and custom attributes

In Solid, the prop:___ directive allows explicit property setting, which is useful for retaining the original data types like objects or arrays. attr:___ directive allows custom attributes, on the other hand, and it is effective for handling string-based HTML attributes.

declare module "solid-js" {
namespace JSX {
interface ExplicitProperties {
count: number;
name: string;
}
interface ExplicitAttributes {
count: number;
name: string;
}
}
}
// Usage
<Input prop:name={name()} prop:count={count()}/>
<my-web-component attr:name={name()} attr:count={count()}/>

Custom directives

In Solid, custom directives can be applied using the use:___ attribute, which usually accepts a target element and a JSX attribute value. The traditional Directives interface types these values directly (i.e. the type of value in <div use:foo={value} />). However, the newer DirectiveFunctions interface takes a function type and derives the valid types for elements and values from it.

There are additional considerations:

  • The directive function always receives a single accessor. For multiple arguments, the syntax <div use:foo={[a, b]} /> is an option, and an accessor to a tuple should be accepted.
  • The same principle holds for boolean directives, as seen in <div use:foo />, and for directives with static values, like <div use:foo={false} />.
  • DirectiveFunctions can accept functions that do not strictly meet the type requirements; such cases will be ignored.
function model(
element: Element, // directives can be used on any HTML and SVG element
value: Accessor<Signal<string>> // second param will always be an accessor in case value being reactive
) {
const [field, setField] = value();
createRenderEffect(() => (element.value = field()));
element.addEventListener("input", (e) => {
const value = (e.target as HTMLInputElement).value;
setField(value);
});
}
declare module "solid-js" {
namespace JSX {
interface Directives {
model: Signal<string>; // Corresponds to `use:model`
}
}
}
// Usage
let [name, setName] = createSignal("");
<input type="text" use:model={[name, setName]} />;

In using DirectiveFunctions, there's the ability to check both arguments (if present) by detailing the entire function type:

function model(element: HTMLInputElement, value: Accessor<Signal<string>>) {
const [field, setField] = value();
createRenderEffect(() => (element.value = field()));
element.addEventListener("input", (e) => setField(e.target.value));
}
function log(element: Element) {
console.log(element);
}
let num = 0;
function count() {
num++;
}
function foo(comp: Element, args: Accessor<string[]>) {
// function body
}
declare module "solid-js" {
namespace JSX {
interface DirectiveFunctions {
model: typeof model;
log: typeof log;
count: typeof count;
foo: typeof foo;
}
}
}

While the Directives interface can limit the value type passed via JSX attribute to the directive, the DirectiveFunctions interface ensures that both element and value align with the expected types, as shown below:

{/* This is correct */}
<input use:model={createSignal('')} />
{/* These will result in a type error */}
<input use:model />
<input use:model={7} />
<div use:model={createSignal('')} />
Addressing import issues with directives

If directives are imported from a separate file or module, TypeScript might mistakenly remove the import thinking it is a type.

To prevent this:

  • Configure onlyRemoveTypeImports: true in babel-preset-typescript.
  • When using vite-plugin-solid, set solidPlugin({ typescript: { onlyRemoveTypeImports: true } }) in vite.config.ts.

Careful management of export type and import type is required. Including a statement in the importing module ensures TypeScript keeps the directive's import. Tree-shaking tools usually omit this code from the final bundle.

import { directive } from "./directives.js"
directive // prevents TypeScript's tree-shaking
<div use:directive />
Report an issue with this page