How to Use SolidJS with TypeScript
Signals in SolidJS are functions. That's elegant for reactivity, but it creates a TypeScript puzzle. When you write <Show when={userData()}>, you've checked that userData() is truthy. Inside the callback, TypeScript still thinks it might be undefined. The type narrowing that works with regular variables doesn't carry across function calls. Since signals are functions, TypeScript can't assume two calls to userData() return the same value—the signal could've changed between calls.
SolidJS is built with TypeScript from the ground up. Every primitive, component type, and reactive API ships with complete type definitions. The framework gives you patterns—like callback props in control flow components—that work with TypeScript's type system instead of against it. This guide shows you how to build reactive SolidJS applications with full type safety, from project setup to advanced patterns that handle signal typing elegantly.
Setting Up SolidJS with TypeScript
Start a new SolidJS project with TypeScript using Vite. The official templates include everything you need:
npm create vite@latest my-solid-app -- --template solid-ts
cd my-solid-app
npm install
npm run dev
This creates a minimal, client-rendered SolidJS application with TypeScript already configured. The template includes a tsconfig.json with one critical setting that differs from React or Vue:
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve", // Critical for SolidJS
"jsxImportSource": "solid-js",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
The "jsx": "preserve" setting matters here. Unlike React, SolidJS compiles JSX to real DOM operations at build time through Vite or your bundler. TypeScript's built-in JSX transformation doesn't understand Solid's reactive primitives, so you preserve the JSX and let Solid's compiler handle it.
If you're building a full-stack application, consider SolidStart, which provides routing, server-side rendering, and API routes with full TypeScript support.
Typing Components and Props in SolidJS
SolidJS provides a Component<T> generic type for defining typed components. Here's how you define a component with typed props:
import { Component, createSignal } from 'solid-js';
interface UserProfileProps {
userId: string;
name: string;
email: string;
role?: 'admin' | 'user';
}
const UserProfile: Component<UserProfileProps> = (props) => {
return (
<div class="profile">
<h2>{props.name}</h2>
<p>{props.email}</p>
{props.role && <span class="badge">{props.role}</span>}
</div>
);
};
// Usage with type checking
<UserProfile
userId="123"
name="Sarah Chen"
email="sarah@example.com"
role="admin"
/>
Using a TypeScript interface for props gives you autocomplete, type checking, and clear documentation of what your component expects. The Component<T> type is just a convenience. You can also use plain functions that return JSX.Element:
// Alternative approach without Component type
function ProductCard(props: {
productId: string;
name: string;
price: number;
inStock: boolean;
}): JSX.Element {
return (
<div class="product">
<h3>{props.name}</h3>
<p>${props.price}</p>
{!props.inStock && <span class="out-of-stock">Out of Stock</span>}
</div>
);
}
Both approaches work the same way. The Component<T> type is just a type alias that makes your intent clearer.
Optional Props and Default Values
Handle optional props by marking them with ? and providing defaults through destructuring:
import { Component, mergeProps } from 'solid-js';
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
onClick?: () => void;
}
const Button: Component<ButtonProps> = (props) => {
// Provide defaults using mergeProps for reactivity
const merged = mergeProps({ variant: 'primary', disabled: false }, props);
return (
<button
class={`btn btn-${merged.variant}`}
disabled={merged.disabled}
onClick={merged.onClick}
>
{merged.label}
</button>
);
};
Notice we're using mergeProps instead of destructuring with defaults. This matters in SolidJS because props are reactive, and direct destructuring breaks reactivity. The mergeProps helper preserves reactivity while providing defaults.
Components with Children
For components that accept children, use the ParentComponent type:
import { ParentComponent } from 'solid-js';
const Card: ParentComponent<{ title: string }> = (props) => {
return (
<div class="card">
<div class="card-header">
<h3>{props.title}</h3>
</div>
<div class="card-body">
{props.children}
</div>
</div>
);
};
// Usage
<Card title="User Details">
<p>This is the card content</p>
<button>Action</button>
</Card>
Working with Signals and Stores in TypeScript
Signals power SolidJS's reactivity. TypeScript infers signal types from their initial values, but explicit typing gives you better control:
import { createSignal } from 'solid-js';
// Type inference - TypeScript knows this is a number signal
const [count, setCount] = createSignal(0);
// Explicit typing - useful when initial value might be undefined
const [userData, setUserData] = createSignal<User | undefined>(undefined);
// TypeScript now knows userData() returns User | undefined
if (userData()) {
console.log(userData()!.name); // Need non-null assertion
}
When you initialize a signal with undefined, TypeScript creates a union type that includes undefined. This works well for data that loads asynchronously:
interface ApiResponse {
data: Product[];
total: number;
}
const [apiData, setApiData] = createSignal<ApiResponse | undefined>();
// Fetch data
async function loadProducts() {
const response = await fetch('/api/products');
const data = await response.json();
setApiData(data);
}
// TypeScript enforces checking before use
const ProductList: Component = () => {
return (
<Show when={apiData()} fallback={<div>Loading...</div>}>
<div>
{apiData()!.data.map(product => (
<div>{product.name}</div>
))}
</div>
</Show>
);
};
TanStack Query's type-safe patterns work identically in Solid as in React, as shown in our TanStack TypeScript guide.
Typing Stores for Complex State
For nested objects and arrays, use createStore with TypeScript interfaces:
import { createStore } from 'solid-js/store';
interface TodoItem {
id: string;
text: string;
completed: boolean;
}
interface AppState {
todos: TodoItem[];
filter: 'all' | 'active' | 'completed';
user: {
name: string;
email: string;
} | null;
}
const [state, setState] = createStore<AppState>({
todos: [],
filter: 'all',
user: null
});
// TypeScript knows the exact shape and provides autocomplete
setState('todos', (todos) => [
...todos,
{ id: crypto.randomUUID(), text: 'New task', completed: false }
]);
// Nested updates are type-safe
setState('user', { name: 'Alice', email: 'alice@example.com' });
The store setter is typed, so you can't accidentally set properties that don't exist or use the wrong types. SolidJS's fine-grained reactivity means only the specific parts of your UI that depend on changed values will re-render.
Reactive Computations with createMemo and createEffect
Type createMemo and createEffect based on what they compute or depend on:
import { createMemo, createEffect } from 'solid-js';
const [firstName, setFirstName] = createSignal('Alice');
const [lastName, setLastName] = createSignal('Johnson');
// createMemo returns a typed accessor
const fullName = createMemo(() => `${firstName()} ${lastName()}`);
// fullName is Accessor<string>, so fullName() returns string
// createEffect doesn't return a value
createEffect(() => {
console.log(`Name changed to: ${fullName()}`);
// This runs whenever firstName or lastName changes
});
You can use generics with createMemo when the computed value has a complex type:
interface CartTotal {
subtotal: number;
tax: number;
total: number;
}
const cartTotal = createMemo<CartTotal>(() => {
const items = cartItems();
const subtotal = items.reduce((sum, item) => sum + item.price, 0);
const tax = subtotal * 0.08;
return { subtotal, tax, total: subtotal + tax };
});
Event Handlers and Refs with TypeScript
SolidJS provides specific types for event handlers that give you correct types for both the event and the target element:
// Use 'import type' for type-only imports to avoid bundling unused code
import type { JSX } from 'solid-js';
const SearchForm: Component = () => {
const [query, setQuery] = createSignal('');
// Properly typed event handler
const handleInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (event) => {
// TypeScript knows event.currentTarget is HTMLInputElement
setQuery(event.currentTarget.value);
};
const handleSubmit: JSX.EventHandler<HTMLFormElement, SubmitEvent> = (event) => {
event.preventDefault();
console.log('Searching for:', query());
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query()}
onInput={handleInput}
placeholder="Search products..."
/>
<button type="submit">Search</button>
</form>
);
};
The JSX.EventHandler<TElement, TEvent> type ensures your event handlers receive the correct event type and that currentTarget points to the right element type.
Working with Refs
Refs in SolidJS let you access DOM elements directly. With TypeScript's strict null checks, you need to handle the fact that refs are undefined until the element mounts:
import { onMount } from 'solid-js';
const FocusInput: Component = () => {
let inputRef: HTMLInputElement | undefined;
onMount(() => {
// Check ref is defined before using it
if (inputRef) {
inputRef.focus();
}
});
return (
<div>
<input
ref={inputRef}
type="text"
placeholder="This input auto-focuses"
/>
</div>
);
};
You can also use callback refs for more control:
const VideoPlayer: Component<{ src: string }> = (props) => {
const setupVideo = (element: HTMLVideoElement) => {
// Element is guaranteed to exist here
element.volume = 0.5;
element.play();
};
return <video ref={setupVideo} src={props.src} />;
};
Advanced Patterns: Generic Components and Context
Generic components let you build reusable UI with type safety. Here's how to create a typed list component:
// Generic component using function declaration
function List<T>(props: {
items: T[];
renderItem: (item: T, index: number) => JSX.Element;
}): JSX.Element {
return (
<ul>
<For each={props.items}>
{(item, index) => <li>{props.renderItem(item, index())}</li>}
</For>
</ul>
);
}
// Usage with full type safety
interface Product {
id: string;
name: string;
price: number;
}
const products: Product[] = [
{ id: '1', name: 'Laptop', price: 999 },
{ id: '2', name: 'Mouse', price: 25 }
];
<List<Product>
items={products}
renderItem={(product) => (
<div>
<span>{product.name}</span>
<span>${product.price}</span>
</div>
)}
/>
Note the syntax difference for generic components. Arrow functions need a constraint (<T extends unknown>) to avoid JSX ambiguity, but function declarations can use generics normally.
Type-Safe Context API
The Context API in SolidJS works great with TypeScript:
import { createContext, useContext, ParentComponent } from 'solid-js';
import { createStore } from 'solid-js/store';
interface AuthContextValue {
user: { name: string; email: string } | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
}
// Create context with undefined default
const AuthContext = createContext<AuthContextValue>();
export const AuthProvider: ParentComponent = (props) => {
const [user, setUser] = createSignal<AuthContextValue['user']>(null);
const login = async (email: string, password: string) => {
// API call here
setUser({ name: 'User', email });
};
const logout = () => {
setUser(null);
};
const contextValue: AuthContextValue = {
get user() { return user(); },
login,
logout
};
return (
<AuthContext.Provider value={contextValue}>
{props.children}
</AuthContext.Provider>
);
};
// Type-safe hook for consuming context
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
Using Utility Types with Stores
TypeScript utility types work well with SolidJS stores for partial updates:
import { createStore } from 'solid-js/store';
interface UserSettings {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
fontSize: number;
}
const [settings, setSettings] = createStore<UserSettings>({
theme: 'light',
notifications: true,
language: 'en',
fontSize: 16
});
// Update only specific fields using Partial
function updateSettings(updates: Partial<UserSettings>) {
setSettings(updates);
}
updateSettings({ theme: 'dark' }); // Only update theme
// Pick specific properties for a component
type ThemeSettings = Pick<UserSettings, 'theme' | 'fontSize'>;
// Omit sensitive settings
type PublicSettings = Omit<UserSettings, 'notifications'>;
The Partial, Pick, and Omit utility types help you work with subsets of your state without repeating type definitions.
Where Developers Get Stuck
Type Narrowing with Signals in Control Flow
Here's where developers hit a wall with SolidJS and TypeScript. Signals are functions, and TypeScript can't narrow the return type of a function across different calls:
const [user, setUser] = createSignal<User | undefined>();
// This seems logical but doesn't work as expected
<Show when={user()}>
{/* TypeScript still thinks user() might be undefined here */}
<div>{user()!.name}</div> {/* Need non-null assertion */}
</Show>
The problem is that user() is a function call, and TypeScript's type narrowing doesn't work across separate function calls. Since signals are functions, TypeScript can't assume that two calls to user() will return the same value. The signal could have changed between calls. Even though SolidJS's fine-grained reactivity ensures consistency within a reactive context, TypeScript's type system doesn't understand this reactive behavior.
The Show component actually helps with this by passing the value to the callback:
// Better: Show passes the non-null value to the callback
<Show when={user()} fallback={<div>Loading...</div>}>
{(userData) => (
// userData is User, not User | undefined
<div>{userData.name}</div>
)}
</Show>
This is the recommended pattern. The callback receives the truthy value, properly narrowed by TypeScript.
Discriminated Unions with Signals
When working with discriminated unions in signals, create a helper to avoid repeated non-null assertions:
type DataState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; data: Product[] };
const [dataState, setDataState] = createSignal<DataState>({ status: 'loading' });
// Helper function for type narrowing
function renderDataState() {
const state = dataState(); // Call once and store
switch (state.status) {
case 'loading':
return <div>Loading...</div>;
case 'error':
return <div>Error: {state.message}</div>;
case 'success':
return <div>{state.data.length} products</div>;
}
}
return <div>{renderDataState()}</div>;
By calling dataState() once and storing the result, TypeScript can properly narrow the type in your switch statement.
Signal Setter Type Issues
In rare cases, you'll encounter type issues with signal setters when using complex union types. Basic unions like string | number work fine, but more complex scenarios need workarounds:
const [value, setValue] = createSignal<string | number>(0);
// This works fine in modern TypeScript
setValue('hello');
setValue(42);
// If you encounter type issues with complex types, use the functional form
setValue((prev) => 'hello');
// Or use type assertion only when necessary
setValue('hello' as string | number);
The functional form setValue((prev) => newValue) is often the cleanest solution when TypeScript has trouble inferring types, and it's also useful when your new value depends on the previous value.
Refs and Strict Null Checks
With strictNullChecks enabled, refs are always T | undefined until the element mounts. Always check before using:
let buttonRef: HTMLButtonElement | undefined;
// Bad: TypeScript error
onMount(() => {
buttonRef.focus(); // Error: Object is possibly undefined
});
// Good: Check first
onMount(() => {
if (buttonRef) {
buttonRef.focus();
}
});
// Also good: Use optional chaining
onMount(() => {
buttonRef?.focus();
});
VSCode Auto-Import Issues
Sometimes VSCode imports types from solid-js/types/server/reactive.js instead of solid-js. If you see weird type errors, check your imports:
// Wrong (from VSCode auto-import)
import { createSignal } from 'solid-js/types/server/reactive';
// Correct
import { createSignal } from 'solid-js';
If this keeps happening, you can add this to your tsconfig.json:
{
"compilerOptions": {
"paths": {
"solid-js": ["./node_modules/solid-js"]
}
}
}
Building Type-Safe SolidJS Applications
Here's a practical example combining everything we've covered: a type-safe product search with API integration.
import { Component, createSignal, createResource, Show, For } from 'solid-js';
interface Product {
id: string;
name: string;
price: number;
category: string;
}
interface SearchResponse {
products: Product[];
total: number;
}
async function searchProducts(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/products?q=${encodeURIComponent(query)}`);
if (!response.ok) {
throw new Error('Search failed');
}
return response.json();
}
const ProductSearch: Component = () => {
const [searchQuery, setSearchQuery] = createSignal('');
const [debouncedQuery, setDebouncedQuery] = createSignal('');
// Debounce search input
const handleInput: JSX.EventHandler<HTMLInputElement, InputEvent> = (event) => {
const value = event.currentTarget.value;
setSearchQuery(value);
setTimeout(() => {
if (searchQuery() === value) {
setDebouncedQuery(value);
}
}, 300);
};
// createResource handles loading and error states
const [searchResults] = createResource(debouncedQuery, searchProducts);
return (
<div class="product-search">
<input
type="search"
value={searchQuery()}
onInput={handleInput}
placeholder="Search products..."
/>
<Show
when={!searchResults.loading}
fallback={<div class="loading">Searching...</div>}
>
<Show
when={searchResults()}
fallback={<div>No results found</div>}
>
{(results) => (
<div class="results">
<p>{results.total} products found</p>
<For each={results.products}>
{(product) => (
<div class="product-card">
<h3>{product.name}</h3>
<p class="price">${product.price.toFixed(2)}</p>
<p class="category">{product.category}</p>
</div>
)}
</For>
</div>
)}
</Show>
</Show>
</div>
);
};
This example demonstrates proper TypeScript usage with SolidJS: typed components, event handlers, resources, and control flow that handles the type narrowing challenges we discussed earlier.
Integrating with Convex
If you need a type-safe backend for your SolidJS application, Convex provides real-time data sync with complete TypeScript support. Convex automatically generates TypeScript types from your backend schema, ensuring your frontend code stays in sync with your database.
While there's no official convex-solid package as of early 2026, you can integrate Convex with SolidJS using createResource and the Convex JavaScript client. Here's how that integration might look:
import { createResource } from 'solid-js';
import { useConvex } from 'convex-solid'; // Example wrapper using Convex JS client
import { api } from '../convex/_generated/api';
const ProductList: Component = () => {
const convex = useConvex();
// Convex provides full type safety for queries
const [products] = createResource(() =>
convex.query(api.products.list, { category: 'electronics' })
);
return (
<Show when={products()}>
{(productList) => (
<For each={productList}>
{(product) => (
// TypeScript knows exact shape from Convex schema
<div>{product.name} - ${product.price}</div>
)}
</For>
)}
</Show>
);
};
The combination of SolidJS's fine-grained reactivity and Convex's real-time updates creates a powerful, type-safe stack.
Final Thoughts
SolidJS and TypeScript work together to give you a reactive framework with complete type safety. Here's what to remember:
- Set
"jsx": "preserve"in your tsconfig.json for Solid's JSX compiler - Use
Component<T>or plain functions returningJSX.Elementfor typed components - Type signals explicitly when dealing with undefined or union types
- Use
Showcomponent callbacks to get properly narrowed types - Store complex state in typed stores with
createStore - Define event handlers with
JSX.EventHandler<TElement, TEvent> - Handle refs as potentially undefined until
onMount - Create helper functions to work around type narrowing limitations with signals
- Use generic components and context for reusable, type-safe patterns
The key difference from React is understanding that signals are functions, which affects how TypeScript narrows types. Once you internalize this pattern and use the callback form of control flow components, you'll build reactive applications with the confidence that TypeScript catches errors before they reach production.
Building a SolidJS app with a type-safe backend? Convex automatically syncs TypeScript types between your frontend and backend, giving you end-to-end type safety for your reactive application.