Skip to content
IC Reactor

Type Safety

IC Reactor provides end-to-end type safety from your Candid definitions all the way to your React components. This guide explains how the type system works and how to get the most out of it.

Candid IDL → generated declarations → IC Reactor → React Hooks
  1. Candid IDL — Your canister’s interface definition (.did file)
  2. Declaration generator — @ic-reactor/vite-plugin, @ic-reactor/cli, @icp-sdk/bindgen, or dfx generate
  3. IC Reactor — Infers types for all hooks and methods
  4. React Components — Full autocomplete and type checking

When your toolchain generates declarations, it creates TypeScript files similar to src/declarations/<canister_name>/:

src/declarations/backend/backend.did.d.ts
import type { ActorMethod } from "@icp-sdk/core/agent"
import type { Principal } from "@icp-sdk/core/principal"
export interface User {
id: string
name: string
email: string
createdAt: bigint
}
export interface CreateUserInput {
name: string
email: string
}
export type CreateUserError =
{ EmailAlreadyExists: null } | { InvalidEmail: null }
export type Result = { Ok: User } | { Err: CreateUserError }
export type GetUserError =
{ NotFound: null } | { Unauthorized: { reason: string } }
export interface _SERVICE {
getUser: ActorMethod<[string], [] | [User]>
get_user: ActorMethod<[string], { Ok: User } | { Err: GetUserError }>
createUser: ActorMethod<[CreateUserInput], Result>
listUsers: ActorMethod<[bigint, bigint], User[]>
getBalance: ActorMethod<[Principal], bigint>
}

Pass the _SERVICE type to defineDisplayReactor for full type inference. It builds the reactor and the hooks bound to it in one call:

src/reactor/hooks.ts
import { defineDisplayReactor } from "@ic-reactor/react"
import { idlFactory, type _SERVICE } from "../declarations/backend"
// TypeScript knows the exact shape of _SERVICE
export const { useActorQuery, useActorMutation } =
defineDisplayReactor<_SERVICE>({
name: "backend",
idlFactory,
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai",
})

The hook examples below import these hooks from ../reactor/hooks. They read display values, because this is a DisplayReactor (see Display Types); defineReactor takes the same options and gives raw Candid values instead, such as [] | [User] for an opt.

The useActorQuery hook infers types automatically:

import { useActorQuery } from "../reactor/hooks"
function UserName() {
const { data } = useActorQuery({
functionName: "getUser", // ✅ Autocomplete shows valid methods
args: ["user-123"], // ✅ Type checked as [string]
})
// data is typed as User | null | undefined (from [] | [User])
return <span>{data?.name}</span>
}

Your IDE will show:

  • All available method names
  • Required argument types
  • Return type for each method

TypeScript catches errors before runtime. Each // @ts-expect-error below marks a line the compiler rejects; remove it and your editor shows the error:

import { useActorQuery } from "../reactor/hooks"
function Examples() {
useActorQuery({
// ❌ 'invalidMethod' does not exist on _SERVICE
// @ts-expect-error
functionName: "invalidMethod",
args: [],
})
useActorQuery({
functionName: "getUser",
// ❌ Type 'number' is not assignable to type 'string'
// @ts-expect-error
args: [123], // Wrong type!
})
// ✅ Correct
useActorQuery({
functionName: "getUser",
args: ["user-123"],
})
return null
}

Mutations are equally type-safe:

import { useActorMutation } from "../reactor/hooks"
function SignUpButton() {
const { mutate } = useActorMutation({
functionName: "createUser", // ✅ Autocomplete
})
// ✅ Type-safe arguments
const signUp = () => mutate([{ name: "Alice", email: "[email protected]" }])
return <button onClick={signUp}>Sign up</button>
}

TypeScript rejects a call that leaves a field out:

import { useActorMutation } from "../reactor/hooks"
function IncompleteSignUpButton() {
const { mutate } = useActorMutation({ functionName: "createUser" })
const signUp = () => {
// ❌ Property 'email' is missing
// @ts-expect-error
mutate([{ name: "Alice" }])
}
return <button onClick={signUp}>Sign up</button>
}

The select option maintains type safety:

import { useActorQuery } from "../reactor/hooks"
function UserSummary({ userId }: { userId: string }) {
// data is User | null | undefined
const { data } = useActorQuery({
functionName: "getUser",
args: [userId],
})
// name is string | undefined (just the name)
const { data: name } = useActorQuery({
functionName: "getUser",
args: [userId],
select: (user) => user?.name, // ✅ user is typed as User | null
})
return (
<p>
{name} {data?.email}
</p>
)
}

Error types are also inferred:

import { useActorQuery } from "../reactor/hooks"
// `get_user` returns a Result<User, GetUserError>
function UserLoadError({ userId }: { userId: string }) {
const { error } = useActorQuery({
functionName: "get_user",
args: [userId],
})
// error is typed as CanisterError<GetUserError in display form> | CallError | ValidationError | null
return error ? <p>{error.message}</p> : null
}

For mutations with specific error types:

import { CanisterError } from "@ic-reactor/react"
import { useActorMutation } from "../reactor/hooks"
function SignUpError() {
const { error } = useActorMutation({
functionName: "createUser",
onError: (error) => {
if (error instanceof CanisterError) {
// error.err is CreateUserError in display form: every case has a `_type`
if (error.err._type === "EmailAlreadyExists") {
toast.error("Email already in use")
}
}
},
})
return error ? <p>{error.message}</p> : null
}

A form, an error view or a helper module often needs one method’s argument, result or error type on its own. Read it off the reactor with typeof, so the service type does not have to be exported next to the reactor and the transform is never spelled out:

import {
DisplayReactor,
type ReactorArgsOf,
type ReactorDataOf,
type ReactorErrorOf,
} from "@ic-reactor/react"
import { idlFactory, type _SERVICE as Ledger } from "./declarations/ledger"
export const ledger = new DisplayReactor<Ledger>({
clientManager,
idlFactory,
name: "ledger",
canisterId: "ryjl3-tyaaa-aaaaa-aaaba-cai",
})
// { owner: string; subaccount?: string | Uint8Array | number[] | null }
type Account = ReactorArgsOf<typeof ledger, "icrc1_balance_of">[0]
// string: a DisplayReactor's nat
type Balance = ReactorDataOf<typeof ledger, "icrc1_balance_of">
// CanisterError<TransferError in display form> | CallError | ValidationError
type TransferError = ReactorErrorOf<typeof ledger, "icrc1_transfer">
Helper Gives Same as
ServiceOf<typeof r> The service type the reactor was built with _SERVICE
TransformOf<typeof r> "candid", "display", or a subclass’s key —
ReactorArgsOf<typeof r, M> The method’s argument tuple ReactorArgs<_SERVICE, M, Transform>
ReactorDataOf<typeof r, M> What the call resolves with (the Ok value) ReactorReturnOk<_SERVICE, M, Transform>
ReactorErrorOf<typeof r, M> What the call rejects with ReactorReturnErr<_SERVICE, M, Transform>

They follow the reactor: a DisplayReactor gives display types, a Reactor raw Candid types, and a candid package reactor its own transform. They take any reactor, Reactor, DisplayReactor or a subclass, and reject a method the service does not have.

A query hook’s data is ReactorDataOf<typeof r, M>, except that a result that can be undefined is cached as null (TanStack Query reserves undefined for “not cached”): ReactorQueryData<ReactorDataOf<typeof r, M>> names it exactly.

With DisplayReactor, types are automatically transformed:

Candid Type Raw TypeScript Display (transformed)
nat bigint string
int bigint string
nat64 / int64 bigint string
nat8/16/32, int8/16/32 number number (unchanged; accepts a numeric string)
principal Principal string
blob Uint8Array string (hex, at every size)
opt T [T] | [] T | undefined (input also takes null)
import {
DisplayReactor,
createActorHooks,
formatTokenAmount,
} from "@ic-reactor/react"
// Create a DisplayReactor directly
const reactor = new DisplayReactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend",
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai",
})
// Pass it to createActorHooks - types are automatically inferred as "display"
const { useActorQuery } = createActorHooks(reactor)
// Inputs and outputs use display types
function Balance() {
const { data } = useActorQuery({
functionName: "getBalance",
args: ["aaaaa-aa"], // Principal as string
})
// data is string instead of bigint: the amount in base units
return <span>{data === undefined ? "…" : formatTokenAmount(data, 8)}</span>
}

With display types, TypeScript knows the transformed shapes:

import { formatTokenAmount } from "@ic-reactor/react"
import { useActorQuery } from "../reactor/hooks"
function Balance({ principalString }: { principalString: string }) {
const { data: balance } = useActorQuery({
functionName: "getBalance",
args: [principalString],
})
// balance is a string of base units, not a bigint: it never needs a
// BigInt() call, and formatTokenAmount places the decimal point
return (
<span>
Balance: {balance === undefined ? "…" : formatTokenAmount(balance, 8)}
</span>
)
}

IC Reactor automatically unwraps Result types:

import { useActorMutation } from "../reactor/hooks"
// Canister method: createUser(input) -> Result<User, CreateUserError>
// IC Reactor:
// - On Ok: returns the User value
// - On Err: throws CanisterError with the error variant
function useSignUp() {
const { mutateAsync } = useActorMutation({
functionName: "createUser",
})
return async () => {
const user = await mutateAsync([
{ name: "Alice", email: "[email protected]" },
])
// user is User (not Result<User, CreateUserError>)
return user
}
}

The error type is available in the error property:

import { useActorMutation } from "../reactor/hooks"
function SignUpStatus() {
const { data, error } = useActorMutation({
functionName: "createUser",
})
// data: User | undefined
// error: CanisterError<CreateUserError in display form> | CallError | ValidationError | null
return <p>{error ? error.message : data?.name}</p>
}

Optional types (opt T) are transformed too:

import { useActorQuery } from "../reactor/hooks"
// Candid: getUser(id) -> opt User (returns [] | [User])
function UserEmail({ userId }: { userId: string }) {
// With display types:
const { data } = useActorQuery({
functionName: "getUser",
args: [userId],
})
// data is User | null once loaded (easier to work with than [] | [User])
return <p>{data ? data.email : "No such user"}</p>
}

Use type narrowing for safer access:

import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) {
const { data, isPending, isError, error } = useActorQuery({
functionName: "get_user",
args: [userId],
})
if (isPending) return <Loading />
if (isError) return <ErrorMessage error={error} />
// TypeScript knows data is defined here
return (
<div>
<h1>{data.name}</h1> {/* ✅ No null check needed */}
<p>{data.email}</p>
</div>
)
}

Create type-safe reusable patterns:

import { skipToken } from "@ic-reactor/react"
import { useActorMutation, useActorQuery } from "../reactor/hooks"
// Type-safe query factory: waits while there is no userId
function useUser(userId: string | undefined) {
return useActorQuery({
functionName: "getUser",
args: userId ? [userId] : skipToken,
})
}
// Type-safe mutation factory
function useCreateUser() {
return useActorMutation({
functionName: "createUser",
})
}
// Usage
function Users({ userId }: { userId: string | undefined }) {
const { data: user } = useUser(userId) // user: User | null | undefined
const { mutate } = useCreateUser() // mutate expects CreateUserInput
// ...
}
  1. Always use the generated _SERVICE type — Pass it to defineDisplayReactor<_SERVICE> (or defineReactor<_SERVICE>, new Reactor<_SERVICE>)
  2. Enable strict TypeScript — Use strict: true in tsconfig.json
  3. Trust the types — If it compiles, the call is structurally valid
  4. Regenerate declarations after Candid changes — Keep types in sync
  5. Use type narrowing — Check for isPending and isError before accessing data

IC Reactor requires strict: true at a minimum. The following additional options are recommended for the best type-safety experience, but are not required:

{
"compilerOptions": {
"strict": true,
"moduleResolution": "bundler",
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
// Recommended for stricter type checking (optional)
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
}
}