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.
How It Works
Section titled “How It Works”Candid IDL → generated declarations → IC Reactor → React Hooks- Candid IDL — Your canister’s interface definition (
.didfile) - Declaration generator —
@ic-reactor/vite-plugin,@ic-reactor/cli,@icp-sdk/bindgen, ordfx generate - IC Reactor — Infers types for all hooks and methods
- React Components — Full autocomplete and type checking
Generated Types
Section titled “Generated Types”When your toolchain generates declarations, it creates TypeScript files similar
to src/declarations/<canister_name>/:
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>}Reactor Setup
Section titled “Reactor Setup”Pass the _SERVICE type to defineDisplayReactor for full type inference. It
builds the reactor and the hooks bound to it in one call:
import { defineDisplayReactor } from "@ic-reactor/react"import { idlFactory, type _SERVICE } from "../declarations/backend"
// TypeScript knows the exact shape of _SERVICEexport 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.
Query Type Inference
Section titled “Query Type Inference”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>}Autocomplete
Section titled “Autocomplete”Your IDE will show:
- All available method names
- Required argument types
- Return type for each method
Compile-Time Safety
Section titled “Compile-Time Safety”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}Mutation Type Inference
Section titled “Mutation Type Inference”Mutations are equally type-safe:
import { useActorMutation } from "../reactor/hooks"
function SignUpButton() { const { mutate } = useActorMutation({ functionName: "createUser", // ✅ Autocomplete })
// ✅ Type-safe arguments
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>}Select Transformations
Section titled “Select Transformations”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
Section titled “Error Types”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}Types From a Reactor
Section titled “Types From a Reactor”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 nattype Balance = ReactorDataOf<typeof ledger, "icrc1_balance_of">// CanisterError<TransferError in display form> | CallError | ValidationErrortype 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.
Display Types (Auto Transformations)
Section titled “Display Types (Auto Transformations)”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) |
Using DisplayReactor
Section titled “Using DisplayReactor”import { DisplayReactor, createActorHooks, formatTokenAmount,} from "@ic-reactor/react"
// Create a DisplayReactor directlyconst 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 typesfunction 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>}Type Assertions
Section titled “Type Assertions”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> )}Result Unwrapping
Section titled “Result Unwrapping”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([ ]) // 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 Unwrapping
Section titled “Optional Unwrapping”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>}Type Narrowing
Section titled “Type Narrowing”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> )}Generic Patterns
Section titled “Generic Patterns”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 userIdfunction useUser(userId: string | undefined) { return useActorQuery({ functionName: "getUser", args: userId ? [userId] : skipToken, })}
// Type-safe mutation factoryfunction useCreateUser() { return useActorMutation({ functionName: "createUser", })}
// Usagefunction Users({ userId }: { userId: string | undefined }) { const { data: user } = useUser(userId) // user: User | null | undefined const { mutate } = useCreateUser() // mutate expects CreateUserInput // ...}Best Practices
Section titled “Best Practices”- Always use the generated
_SERVICEtype — Pass it todefineDisplayReactor<_SERVICE>(ordefineReactor<_SERVICE>,new Reactor<_SERVICE>) - Enable strict TypeScript — Use
strict: truein tsconfig.json - Trust the types — If it compiles, the call is structurally valid
- Regenerate declarations after Candid changes — Keep types in sync
- Use type narrowing — Check for
isPendingandisErrorbefore accessingdata
TypeScript Configuration
Section titled “TypeScript Configuration”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 }}Further Reading
Section titled “Further Reading”- Queries — Type-safe query patterns
- Mutations — Type-safe mutation patterns
- Error Handling — Type-safe error handling