Skip to content
IC Reactor

Values and units

A module that candid-core-cli gen writes types every Candid value as plain TypeScript. There are no wrapper classes. This page lists the shapes, shows how to make arguments, and shows how to convert token amounts exactly.

Candid TypeScript
nat, int, nat64, int64 bigint (never number)
nat8..nat32, int8..int32, floats number
text, bool string, boolean
principal Principal: branded principal text
blob, vec nat8 Uint8Array
opt T T | null (the property is always present)
opt opt T, opt null { some: T | null } | null, { some: null } | null
vec T T[]
record { a : T } { a: T }, every field present
variant { A : T; B } { tag: "A"; value: T } | { tag: "B" }

Three consequences:

  • bigint for amounts. A nat is a bigint, so a ledger’s balance is bigint too. icrc1_decimals is a nat8, so it is a number.
  • null for an empty opt. The property is always there. A record with an optional field is written with null, not by leaving the field out.
  • Variants are { tag, value }. A variant arm with no payload has no value.

An ICRC-1 transfer shows all three:

import { principal } from "@candid-core/schema"
import type { Account, TransferArg, TransferError } from "./generated/icrc1"
const to: Account = {
owner: principal("aaaaa-aa"),
subaccount: null, // opt blob
}
const arg: TransferArg = {
to,
amount: 150_000_000n, // nat
fee: null,
memo: null,
from_subaccount: null,
created_at_time: null,
}
export function describe(error: TransferError): string {
switch (error.tag) {
case "InsufficientFunds":
return `the balance is ${error.value.balance}`
case "BadFee":
return `the fee is ${error.value.expected_fee}`
case "TooOld":
return "created_at_time is too old" // no value on this arm
default:
return error.tag
}
}

A Principal is principal text with a brand, so a plain string does not pass for one. Make one with principal(text) from @candid-core/schema. It accepts only canonical text and throws a TypeError for anything else, so catch it where the text comes from a user:

import { isPrincipal, principal, type Principal } from "@candid-core/schema"
export function parseOwner(text: string): Principal | undefined {
try {
return principal(text.trim())
} catch {
return undefined // refuse the input
}
}
export function isOwner(text: string): boolean {
return isPrincipal(text) // in render: no throw
}

principal also takes an @icp-sdk/core Principal, or any object with a toText() method. isPrincipal(value) is the same check without the throw: use it first in render, for example to pass skipToken until a typed owner is valid. Never cast text to Principal. A cast skips the check, and the value is refused later, when it is encoded.

principal and isPrincipal come from @candid-core/schema only. @ic-reactor/core and @ic-reactor/react do not re-export them.

  • A direct call takes the parameters as type Actor lists them: ledger.icrc1_balance_of(account), backend.set_name(owner, "Ada").
  • The builders, client.queryOptions and client.mutationOptions, take the variables as one value: nothing for a method with no arguments, the value for one argument, and a tuple [a, b] for several.
import { principal } from "@candid-core/schema"
import { client } from "./ledger"
import { actor, type Actor } from "./generated/backend"
const backend = client.canister<Actor>(actor, {
id: "rrkah-fqaaa-aaaaa-aaaaq-cai",
})
const me = principal("aaaaa-aa")
// Direct: the parameters, as Actor lists them.
await backend.get_profile(me)
// Builders: one value for one argument. A method with no arguments takes
// nothing, as in client.queryOptions(ledger, "icrc1_fee").
const getProfile = client.queryOptions(backend, "get_profile", me)
// set_name has two parameters, so its variables are a tuple:
const setName = client.mutationOptions(backend, "set_name")
// useMutation(setName).mutate([me, "Ada"])

A method whose single result is variant { Ok : T; Err : E } resolves with the Ok value. The Err value rejects as a ReactorError of kind "canister_err", with the value in err, typed on a query’s or mutation’s error. See Errors and mayHaveExecuted.

A ledger counts in base units: e8s for ICP, satoshis for ckBTC, wei for ckETH. parseUnits and formatUnits convert between those and the decimal text a person reads and types. Neither goes through Number, so every amount a ledger can hold converts exactly in both directions.

const lost = Math.floor(Number("0.29") * 10 ** 8)
console.log(lost) // 28999999: a transfer of 0.29 would send 0.28999999
const wei = BigInt(Math.floor(Number("1.1") * 1e18))
console.log(wei) // 1100000000000000128n: not 1.1 tokens at 18 decimals

The helpers work on the digits, not on a Number.

parseUnits(text, decimals, options?) reads text a person typed and returns base units as a bigint:

import { parseUnits } from "@ic-reactor/core"
parseUnits("1.5", 8) // 150000000n
parseUnits("0.29", 8) // 29000000n
parseUnits("1.1", 18) // 1100000000000000000n
parseUnits(" 5. ", 8) // 500000000n: whitespace around the number, and "5.", are fine
parseUnits(".5", 8) // 50000000n
parseUnits("1.000000000", 8) // 100000000n: the extra zeros change nothing
parseUnits("-1", 8, { signed: true }) // -100000000n, for an int amount

It refuses anything it would have to guess at:

Input Result
not text, blank, letters TypeError
grouping ("1,5"), exponents ("1e3"), a leading + TypeError
a lone ., whitespace inside the number TypeError
decimals that is not a whole number TypeError
more significant fraction digits than decimals RangeError
a negative amount without signed: true RangeError
decimals outside 0 to 255 RangeError

"0.123456789" at 8 decimals is no amount of e8s, and rounding it would send something other than what was typed, so it is refused. In a form, catch both errors, show the message, and send nothing:

import { parseUnits } from "@ic-reactor/core"
export function readAmount(input: string, decimals: number): bigint | string {
try {
return parseUnits(input, decimals)
} catch (error) {
return (error as Error).message
}
}

There is no upper bound on the result. The limit belongs to the Candid type: a nat64 field refuses 2^64 when the call is encoded, as invalid_args, before anything is sent, and an ICRC-1 amount is a nat, which has no limit.

formatUnits(value, decimals, options?) shows base units as plain decimal text. The text is only -, the digits and ., the same on a server and in every browser, and the form parseUnits reads back.

import { formatUnits } from "@ic-reactor/core"
formatUnits(150_000_000n, 8) // "1.5": trailing zeros are dropped
formatUnits(100_000_000n, 8) // "1"
formatUnits(123_456_789n, 8, { maxFractionDigits: 2 }) // "1.23"
formatUnits(99_999_999n, 8, { maxFractionDigits: 2 }) // "0.99", never "1"
formatUnits(100_000_000n, 8, { minFractionDigits: 2 }) // "1.00"
formatUnits(-1n, 8) // "-0.00000001"
  • maxFractionDigits cuts the digits past it toward zero and never rounds up. A display never shows more than is held.
  • minFractionDigits pads the fraction with zeros instead of trimming them.
  • There is no "-0": a negative amount whose shown digits are all zero is written without the sign.
  • formatUnits throws a TypeError when value is not a bigint, and a RangeError for digit options outside 0 to 255 or a minFractionDigits above maxFractionDigits.

For a locale’s separators, format the text yourself with Intl.NumberFormat. It reads a decimal string exactly, but it rounds to 3 fraction digits unless you say otherwise, so give it a maximumFractionDigits as large as the digits the text has:

/// <reference lib="es2023.intl" />
import { formatUnits } from "@ic-reactor/core"
const text = formatUnits(123_456_789_000n, 8) // "1234.56789"
const shown = new Intl.NumberFormat("de-DE", {
maximumFractionDigits: 8,
}).format(
text as Intl.StringNumericLiteral // the cast needs lib ES2023
)
console.log(shown) // "1.234,56789"

Before lib ES2023, the Intl types accept only a number or a bigint, and a number loses digits. That is why the cast, and the reference line, are there.

Next: Reads and Writes use these values.