Introduction to SolidJS with TypeScript
SolidJS has emerged as one of the most performant reactive UI libraries in the JavaScript ecosystem. When paired with TypeScript, it becomes a powerhouse for building strongly typed, maintainable applications. This tutorial explores how to leverage TypeScript's static typing system alongside SolidJS's fine-grained reactivity to create robust, type-safe applications.
What is SolidJS?
SolidJS is a declarative JavaScript library for creating user interfaces. Unlike React, it does not use a virtual DOM. Instead, it compiles its JSX to real DOM nodes and updates only the specific parts of the UI that change through fine-grained reactivity. This approach delivers exceptional performance while maintaining a developer-friendly API that feels familiar to React developers.
Why TypeScript Matters with SolidJS
TypeScript brings static typing to JavaScript, catching errors at compile time rather than runtime. When combined with SolidJS, TypeScript helps you:
- Catch prop type mismatches before they reach production
- Get intelligent autocompletion for reactive primitives and component APIs
- Enforce consistent data shapes across your reactive state
- Document component contracts explicitly through interfaces
- Refactor with confidence across large codebases
Setting Up a SolidJS TypeScript Project
The easiest way to start a SolidJS project with TypeScript is using the official Vite template. Open your terminal and run:
npx degit solidjs/templates/ts my-solid-app
cd my-solid-app
npm install
npm run dev
This scaffolds a project with TypeScript configured out of the box. The key configuration lives in tsconfig.json:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "node",
"jsx": "preserve",
"jsxImportSource": "solid-js",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vite/client"]
}
}
The critical setting here is jsxImportSource: "solid-js", which tells TypeScript to use SolidJS's JSX runtime rather than React's.
Typing Components and Props
Basic Component Typing
In SolidJS, components are functions that return JSX. The idiomatic way to type a component is using the Component utility type or a plain function signature with typed props:
import { Component } from "solid-js";
interface GreetingProps {
name: string;
enthusiasm?: number;
}
const Greeting: Component<GreetingProps> = (props) => {
const level = () => "!".repeat(props.enthusiasm ?? 1);
return <h1>Hello, {props.name}{level()}</h1>;
};
export default Greeting;
The Component type automatically infers the props type from the generic argument. However, many SolidJS developers prefer plain function declarations for better type inference and simpler mental models:
function Greeting(props: GreetingProps) {
const level = () => "!".repeat(props.enthusiasm ?? 1);
return <h1>Hello, {props.name}{level()}</h1>;
}
Working with Children
SolidJS provides a JSX.Element type and a children helper for handling nested content. Here is how you type a component that accepts children:
import { children, Component } from "solid-js";
interface CardProps {
title: string;
children: JSX.Element;
}
const Card: Component<CardProps> = (props) => {
const resolved = children(() => props.children);
return (
<div class="card">
<h2>{props.title}</h2>
<div class="card-body">{resolved()}</div>
</div>
);
};
Using the children helper ensures that any reactive children are properly memoized, preventing unnecessary re-renders.
Typing Reactive Primitives
Signals
Signals are the core reactive primitive in SolidJS. The createSignal function is fully generic, so TypeScript infers the type from the initial value. You can also specify the type explicitly:
import { createSignal } from "solid-js";
// Inferred as Accessor<number> and Setter<number>
const [count, setCount] = createSignal(0);
// Explicit typing for union types
const [status, setStatus] = createSignal<"idle" | "loading" | "success" | "error">("idle");
// Explicit typing when initial value might be undefined
const [user, setUser] = createSignal<User | null>(null);
The setter function is also strongly typed. If your signal holds a number, TypeScript prevents you from setting it to a string:
setCount(5); // Valid
setCount("five"); // Type error: Argument of type 'string' is not assignable to 'number'
setCount((prev) => prev + 1); // Valid, updater function is typed
Stores
For complex nested state, SolidJS provides createStore. TypeScript works beautifully with stores when you define an interface for the state shape:
import { createStore } from "solid-js/store";
interface TodoItem {
id: string;
text: string;
completed: boolean;
}
interface TodoState {
items: TodoItem[];
filter: "all" | "active" | "completed";
}
const [state, setState] = createStore<TodoState>({
items: [],
filter: "all",
});
// Type-safe updates
setState("filter", "active");
setState("items", (items) => [
...items,
{ id: crypto.randomUUID(), text: "Learn SolidJS", completed: false },
]);
// Deep updates are also type-checked
setState("items", 0, "completed", true);
Memos and Derived State
createMemo creates a memoized computation. The return type is inferred from the function you pass:
import { createMemo } from "solid-js";
const [firstName, setFirstName] = createSignal("Jane");
const [lastName, setLastName] = createSignal("Doe");
// Inferred as Accessor<string>
const fullName = createMemo(() => `${firstName()} ${lastName()}`);
// Explicit typing when needed
const wordCount = createMemo<number>(() => fullName().split(" ").length);
Typing Context
Context in SolidJS allows you to share state across the component tree without prop drilling. To make context type-safe, define an interface and use a factory pattern that handles the case where context is used outside its provider:
import { createContext, useContext, Context, ParentComponent } from "solid-js";
interface ThemeContextValue {
theme: () => "light" | "dark";
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue>();
export const ThemeProvider: ParentComponent = (props) => {
const [theme, setTheme] = createSignal<"light" | "dark">("light");
const value: ThemeContextValue = {
theme,
toggleTheme: () => setTheme((t) => (t === "light" ? "dark" : "light")),
};
return (
<ThemeContext.Provider value={value}>
{props.children}
</ThemeContext.Provider>
);
};
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return ctx;
}
The useTheme hook throws a descriptive error if called outside the provider, which is a best practice for catching misuse early during development.
Typing Event Handlers
SolidJS events are typed through the JSX namespace. You can specify event types explicitly when needed, but often inference handles it for you:
import { Component } from "solid-js";
const SearchForm: Component = () => {
const [query, setQuery] = createSignal("");
// Event type is inferred from the JSX element
const handleInput = (e: InputEvent) => {
setQuery((e.currentTarget as HTMLInputElement).value);
};
const handleSubmit = (e: Event) => {
e.preventDefault();
console.log("Searching for:", query());
};
return (
<form onSubmit={handleSubmit}>
<input type="text" value={query()} onInput={handleInput} />
<button type="submit">Search</button>
</form>
);
};
For convenience, SolidJS also exports event type aliases like JSX.EventHandler for inline handlers:
const handleClick: JSX.EventHandler<HTMLButtonElement, MouseEvent> = (event) => {
// event.currentTarget is typed as HTMLButtonElement
console.log(event.currentTarget.textContent);
};
Typing Resources and Async Data
SolidJS's createResource handles asynchronous data fetching with built-in loading and error states. Typing it properly ensures your data shape is enforced:
import { createResource, Component, Show, For } from "solid-js";
interface User {
id: number;
name: string;
email: string;
}
async function fetchUsers(): Promise<User[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/users");
if (!response.ok) throw new Error("Failed to fetch users");
return response.json();
}
const UserList: Component = () => {
const [users] = createResource(fetchUsers);
return (
<div>
<Show when={users.loading}>
<p>Loading users...</p>
</Show>
<Show when={users.error}>
<p>Error: {(users.error as Error).message}</p>
</Show>
<Show when={users()}>
<ul>
<For each={users()}>
{(user) => (
<li>
<strong>{user.name}</strong> - {user.email}
</li>
)}
</For>
</ul>
</Show>
</div>
);
};
The users resource is typed as Resource<User[]>, so calling users() returns User[] | undefined. The Show component narrows the type, ensuring user.name and user.email are safely accessible inside the callback.
Best Practices for SolidJS TypeScript Applications
Prefer Interfaces Over Type Aliases for Props
Interfaces are extendable and produce better error messages. Use them for component props and state shapes:
// Preferred
interface ButtonProps {
variant: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
onClick: () => void;
children: JSX.Element;
}
// Less ideal for props
type ButtonPropsAlt = {
variant: "primary" | "secondary" | "danger";
};
Use Strict Mode and Enable All Checks
Enable strict TypeScript settings to catch issues early. Add these to your tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true
}
}
The noUncheckedIndexedAccess option is particularly valuable when working with arrays from stores or resources, as it forces you to handle undefined values from array access.
Create Typed Utility Functions for Common Patterns
Build reusable, typed helpers for patterns you use frequently. For example, a typed factory for creating form state:
import { createSignal } from "solid-js";
interface FormField<T> {
value: () => T;
setValue: (v: T) => void;
error: () => string | null;
setError: (e: string | null) => void;
touched: () => boolean;
setTouched: (t: boolean) => void;
}
function createField<T>(initial: T): FormField<T> {
const [value, setValue] = createSignal<T>(initial);
const [error, setError] = createSignal<string | null>(null);
const [touched, setTouched] = createSignal(false);
return { value, setValue, error, setError, touched, setTouched };
}
// Usage
const email = createField("");
const age = createField(0);
email.setValue("test@example.com"); // Valid
email.setValue(42); // Type error
Leverage Discriminated Unions for State Machines
For complex state transitions, discriminated unions give you exhaustive type checking:
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function handleState<T>(state: RequestState<T>) {
switch (state.status) {
case "idle":
return "Waiting to start";
case "loading":
return "Loading...";
case "success":
return `Got data: ${JSON.stringify(state.data)}`;
case "error":
return `Failed: ${state.error}`;
}
}
TypeScript ensures every case is handled, and if you add a new status later, the compiler will flag every switch statement that needs updating.
Avoid Destructuring Props
In SolidJS, destructuring props breaks reactivity because it evaluates the property once at the time of destructuring. Always access props directly:
// Wrong - breaks reactivity
const MyComponent: Component<MyProps> = ({ title, count }) => {
return <h1>{title} ({count})</h1>;
};
// Correct - preserves reactivity
const MyComponent: Component<MyProps> = (props) => {
return <h1>{props.title} ({props.count})</h1>;
};
If you need to split props, use the splitProps utility, which maintains reactivity:
import { splitProps, Component } from "solid-js";
interface ButtonProps {
variant: "primary" | "secondary";
label: string;
onClick: () => void;
disabled: boolean;
}
const Button: Component<ButtonProps> = (props) => {
const [local, rest] = splitProps(props, ["variant", "label"]);
return (
<button class={`btn btn-${local.variant}`} {...rest}>
{local.label}
</button>
);
};
Putting It All Together
Here is a complete example combining signals, stores, context, resources, and typed components into a small task management application:
import {
Component,
createSignal,
createResource,
Show,
For,
ParentComponent,
createContext,
useContext,
} from "solid-js";
import { createStore } from "solid-js/store";
// Types
interface Task {
id: string;
title: string;
done: boolean;
}
interface TaskStore {
tasks: Task[];
addTask: (title: string) => void;
toggleTask: (id: string) => void;
}
// Context
const TaskContext = createContext<TaskStore>();
export const TaskProvider: ParentComponent = (props) => {
const [tasks, setTasks] = createStore<Task[]>([]);
const store: TaskStore = {
tasks,
addTask: (title) =>
setTasks((prev) => [
...prev,
{ id: crypto.randomUUID(), title, done: false },
]),
toggleTask: (id) =>
setTasks(
(t) => t.id === id,
"done",
(done) => !done
),
};
return <TaskContext.Provider value={store}>{props.children}</TaskContext.Provider>;
};
function useTasks(): TaskStore {
const ctx = useContext(TaskContext);
if (!ctx) throw new Error("useTasks must be used within TaskProvider");
return ctx;
}
// Components
const TaskItem: Component<{ task: Task }> = (props) => {
const { toggleTask } = useTasks();
return (
<li>
<input
type="checkbox"
checked={props.task.done}
onChange={() => toggleTask(props.task.id)}
/>
<span style={{ "text-decoration": props.task.done ? "line-through" : "none" }}>
{props.task.title}
</span>
</li>
);
};
const TaskInput: Component = () => {
const { addTask } = useTasks();
const [input, setInput] = createSignal("");
const submit = (e: Event) => {
e.preventDefault();
const value = input().trim();
if (value) {
addTask(value);
setInput("");
}
};
return (
<form onSubmit={submit}>
<input
type="text"
value={input()}
onInput={(e) => setInput(e.currentTarget.value)}
placeholder="Add a task..."
/>
<button type="submit">Add</button>
</form>
);
};
const TaskList: Component = () => {
const { tasks } = useTasks();
return (
<Show when={tasks.length > 0} fallback={<p>No tasks yet.</p>}>
<ul>
<For each={tasks}>
{(task) => <TaskItem task={task} />}
</For>
</ul>
</Show>
);
};
const App: Component = () => {
return (
<TaskProvider>
<div>
<h1>Task Manager</h1>
<TaskInput />
<TaskList />
</div>
</TaskProvider>
);
};
export default App;
Conclusion
SolidJS and TypeScript are a natural pairing that brings together high-performance reactivity and compile-time type safety. By defining clear interfaces for your props, stores, and context values, you create a self-documenting codebase where the compiler catches mistakes before your users do. The key practices to remember are: avoid destructuring props to preserve reactivity, use discriminated unions for complex state, leverage splitProps for prop splitting, and enable strict TypeScript settings to get the most out of the type system. As your application grows, these typed foundations pay dividends in maintainability, developer confidence, and fewer runtime errors, letting you focus on building features rather than debugging type-related bugs.