Skip to content

FieldVisitor

FieldVisitor generates comprehensive form field metadata from Candid IDL types. Use it to build dynamic forms that adapt to any canister interface without manual type mapping.

The visitor traverses Candid IDL types and produces metadata that includes:

  • Field types with validation schemas
  • Display labels automatically formatted from raw Candid labels
  • Component hints for UI rendering
  • Input props ready for HTML elements
  • Helper methods for dynamic forms (variants, optionals, vectors)
import { FieldVisitor } from "@ic-reactor/candid"
import { IDL } from "@icp-sdk/core/candid"
const visitor = new FieldVisitor()
const service = idlFactory({ IDL })
// Generate metadata for all methods
const serviceMeta = service.accept(visitor, null)
// Get metadata for a specific method
const transferMeta = serviceMeta["icrc1_transfer"]
console.log(transferMeta.fields) // Field definitions
console.log(transferMeta.schema) // Zod validation schema
import { FieldVisitor } from "@ic-reactor/candid"
new FieldVisitor<A = BaseActor>()

The generic parameter A allows type inference for your actor’s method names.


const visitor = new FieldVisitor()
// Visit a service to get metadata for all methods
const serviceMeta = service.accept(visitor, null)
// Access a specific method's metadata
const methodMeta = serviceMeta["my_method"]
console.log(methodMeta.functionType) // "query" | "update"
console.log(methodMeta.fields) // Array of Field objects
console.log(methodMeta.schema) // Zod tuple schema for validation
console.log(methodMeta.defaultValues) // Default values for form initialization
import { useForm } from '@tanstack/react-form'
import { FieldVisitor } from '@ic-reactor/candid'
const visitor = new FieldVisitor()
const methodMeta = serviceMeta["icrc1_transfer"]
const form = useForm({
defaultValues: methodMeta.defaultValues,
validators: { onBlur: methodMeta.schema },
onSubmit: async ({ value }) => {
await actor.icrc1_transfer(...value)
}
})
// Render fields dynamically
methodMeta.fields.map((field, index) => (
<form.Field key={index} name={field.name}>
{(fieldApi) => <DynamicInput field={field} fieldApi={fieldApi} />}
</form.Field>
))

Each field has the following base properties:

PropertyTypeDescription
typeArgumentFieldTypeThe field type (record, variant, text, etc.)
labelstringRaw label from Candid (__arg0, _0_, etc.)
displayLabelstringHuman-readable formatted label
namestringTanStack Form compatible path
componentFieldComponentTypeSuggested component type for rendering
renderHintRenderHintUI rendering strategy hints
schemaz.ZodTypeAnyZod schema for validation
defaultValueTValueInitial value for the field
candidTypestringOriginal Candid type name

The component property suggests what UI component to render:

Component TypeUse Case
record-containerRecord with nested fields
tuple-containerTuple with indexed elements
variant-selectVariant with option selector
optional-toggleOptional field with enable/disable
vector-listDynamic list with add/remove
blob-uploadFile upload or hex input
principal-inputPrincipal ID input
text-inputText/string input
number-inputNumeric input (bounded types)
boolean-checkboxBoolean toggle
null-hiddenNull type (typically hidden)
recursive-lazyLazy-loaded recursive type
unknown-fallbackFallback for unknown types
const componentMap = {
"text-input": TextInput,
"number-input": NumberInput,
"boolean-checkbox": BooleanCheckbox,
"record-container": RecordContainer,
"variant-select": VariantSelect,
"optional-toggle": OptionalToggle,
"vector-list": VectorList,
"blob-upload": BlobUpload,
"principal-input": PrincipalInput,
"null-hidden": () => null,
"recursive-lazy": RecursiveField,
"unknown-fallback": FallbackField,
}
function DynamicField({ field }: { field: Field }) {
const Component = componentMap[field.component]
return <Component field={field} />
}

The renderHint property helps determine rendering strategy:

interface RenderHint {
isCompound: boolean // true for record, variant, tuple, optional, vector
isPrimitive: boolean // true for text, number, boolean, null, principal
inputType?: InputType // 'text' | 'number' | 'checkbox' | 'select' | 'file'
description?: string // Help text from Candid
}
function FieldRenderer({ field }: { field: Field }) {
if (field.renderHint.isCompound) {
return (
<Card title={field.displayLabel}>
<CompoundFieldContent field={field} />
</Card>
)
}
return (
<FormField label={field.displayLabel}>
<PrimitiveInput field={field} />
</FormField>
)
}

Primitive fields include pre-computed HTML input props:

interface PrimitiveInputProps {
type?: "text" | "number" | "checkbox"
placeholder?: string
min?: string | number
max?: string | number
step?: string | number
pattern?: string
inputMode?: "text" | "numeric" | "decimal"
autoComplete?: string
spellCheck?: boolean
minLength?: number
maxLength?: number
}
function TextInput({ field }: { field: TextField }) {
return (
<input
{...field.inputProps}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)
}

Records have nested fields accessible by label:

interface RecordField {
type: "record"
fields: Field[] // All nested fields
fieldMap: Map<string, Field> // Lookup by label
defaultValue: Record<string, unknown>
}

Variants include helper methods for selection:

interface VariantField {
type: "variant"
fields: Field[] // All option fields
options: string[] // Option names
defaultOption: string // First option
optionMap: Map<string, Field> // Lookup by option
// Helper methods
getOptionDefault(option: string): Record<string, unknown>
getField(option: string): Field
getSelectedOption(value: Record<string, unknown>): string
getSelectedField(value: Record<string, unknown>): Field
}
function VariantField({ field, value, onChange }) {
const selectedOption = field.getSelectedOption(value)
const selectedField = field.getField(selectedOption)
return (
<div>
<select
value={selectedOption}
onChange={(e) => onChange(field.getOptionDefault(e.target.value))}
>
{field.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
<DynamicField field={selectedField} />
</div>
)
}

Optional fields can be toggled:

interface OptionalField {
type: "optional"
innerField: Field // The wrapped field
defaultValue: null // Always starts as null
// Helper methods
getInnerDefault(): unknown
isEnabled(value: unknown): boolean
}
function OptionalField({ field, value, onChange }) {
const isEnabled = field.isEnabled(value)
return (
<div>
<label>
<input
type="checkbox"
checked={isEnabled}
onChange={(e) =>
onChange(e.target.checked ? field.getInnerDefault() : null)
}
/>
Enable {field.displayLabel}
</label>
{isEnabled && <DynamicField field={field.innerField} />}
</div>
)
}

Vectors support dynamic item creation:

interface VectorField {
type: "vector"
itemField: Field // Template for items
defaultValue: [] // Empty array
// Helper methods
getItemDefault(): unknown
createItemField(index: number, overrides?: { label?: string }): Field
}
function VectorField({ field, value, onChange }) {
const addItem = () => {
onChange([...value, field.getItemDefault()])
}
const removeItem = (index: number) => {
onChange(value.filter((_, i) => i !== index))
}
return (
<div>
{value.map((item, index) => {
const itemField = field.createItemField(index)
return (
<div key={index}>
<DynamicField field={itemField} />
<button onClick={() => removeItem(index)}>Remove</button>
</div>
)
})}
<button onClick={addItem}>Add Item</button>
</div>
)
}

Blob fields include validation utilities:

interface BlobField {
type: "blob"
itemField: Field
acceptedFormats: ("hex" | "base64" | "file")[]
limits: {
maxHexBytes: number // 512
maxFileBytes: number // 2MB
maxHexDisplayLength: number
}
// Helper methods
normalizeHex(input: string): string
validateInput(value: string | Uint8Array): { valid: boolean; error?: string }
}

The displayLabel property is automatically formatted from raw Candid labels:

Raw LabelDisplay Label
__arg0Arg 0
_0_Item 0
created_at_timeCreated At Time
userAddressUser Address

You can also use the exported formatLabel function:

import { formatLabel } from "@ic-reactor/candid"
formatLabel("__arg0") // "Arg 0"
formatLabel("_0_") // "Item 0"
formatLabel("created_at_time") // "Created At Time"

The package exports type guard utilities:

import {
isFieldType,
isCompoundField,
isPrimitiveField,
hasChildFields,
} from "@ic-reactor/candid"
// Check specific field type
if (isFieldType(field, "record")) {
// field is now typed as RecordField
console.log(field.fields)
}
// Check compound vs primitive
if (isCompoundField(field)) {
// field is RecordField | VariantField | TupleField | OptionalField | VectorField | RecursiveField
}
if (isPrimitiveField(field)) {
// field is PrincipalField | NumberField | TextField | BooleanField | NullField
}
// Check if field has children
if (hasChildFields(field)) {
// field is RecordField | VariantField | TupleField
field.fields.forEach((child) => console.log(child.label))
}

Use FieldProps<T> to type your field components:

import type { FieldProps } from "@ic-reactor/candid"
const VariantInput: React.FC<FieldProps<"variant">> = ({ field }) => {
// field is properly typed as VariantField
return (
<select>
{field.options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
)
}

import { FieldVisitor, Field, isFieldType } from "@ic-reactor/candid"
import { useForm } from "@tanstack/react-form"
function DynamicForm({ service, methodName }) {
const visitor = new FieldVisitor()
const serviceMeta = service.accept(visitor, null)
const methodMeta = serviceMeta[methodName]
const form = useForm({
defaultValues: methodMeta.defaultValues,
validators: { onChange: methodMeta.schema },
onSubmit: async ({ value }) => {
// Call the canister method
},
})
const renderField = (field: Field) => {
if (isFieldType(field, "record")) {
return (
<fieldset>
<legend>{field.displayLabel}</legend>
{field.fields.map((f) => renderField(f))}
</fieldset>
)
}
if (isFieldType(field, "text")) {
return (
<form.Field name={field.name}>
{(fieldApi) => (
<label>
{field.displayLabel}
<input {...field.inputProps} {...fieldApi.getInputProps()} />
</label>
)}
</form.Field>
)
}
// ... handle other field types
}
return (
<form onSubmit={form.handleSubmit}>
{methodMeta.fields.map(renderField)}
<button type="submit">Submit</button>
</form>
)
}