OfficeGest
Form Builder

Custom Field Types

Register your own field-type renderers via the slot system, with the licensePlate worked example.

The form builder ships 17 built-in field types (text, select, currency, file, array, …). When a module needs an input the built-ins don't cover — a license-plate widget, a tax-ID field with a live checksum, a colour swatch — it registers its own renderer through the slot system. No code inside src/components/forms/ ever learns about the custom type; the contributing module owns it end to end.

This page documents that mechanism and walks through the licensePlate field as a complete worked example.

The slot system is for open extensibility — letting any module add a field type the builder has never heard of. If your module just needs a specific component from another module, import it directly. Reach for slots only when the builder must stay ignorant of who is extending it.


The two halves of a custom field type

Registering a field type is two independent contributions that happen to share one key ("licensePlate"):

HalfFileWhat it buys you
Runtimemodule.ts slot contributionAt render time, the builder resolves type: "licensePlate" to your component.
Type-levela .d.ts declaration mergetype: "licensePlate" autocompletes inside FormFields<T>, and your renderer's config is typed for that entry.

Both flow through the same FieldTypeRegistry interface key. Skip the runtime half and the field throws at render; skip the type-level half and it still works at runtime but you lose autocomplete and compile-time checking.


The slot contract

The builder publishes one slot constant and one contribution shape from @/components/forms:

const FORM_SLOTS = {
  FIELD_TYPES: "forms:field-types",
} as const;

type FormFieldType = {
  type: string;
  component: ComponentType<{
    name: string;
    config: unknown;
    field: AnyFieldApi;
  }>;
  order?: number;
};
FieldMeaning
typeThe discriminator matched against a field config's type. Must equal the key you declaration-merge into FieldTypeRegistry.
componentYour renderer. Its field prop is TanStack Form's own AnyFieldApi — not a custom adapter — so renderers track upstream changes. config is widened to unknown here so any registry entry is assignable across the slot boundary.
orderSorts contributions; lower numbers win when two modules contribute the same type. Defaults to 50.

The slot's config: unknown is intentionally the widest possible type so a contribution of any field type stays assignable. Your renderer narrows it back to its real config shape via the FieldRenderer<T> generic — see the next section.


Typing the renderer

Type your component with FieldRenderer<TConfig> (or its prop type FieldRendererProps<TConfig>) from @/components/forms:

type FieldRendererProps<TConfig extends FieldConfig = FieldConfig> = {
  name: string;
  config: ResolveConfig<TConfig>;
  field: AnyFieldApi;
};

type FieldRenderer<TConfig extends FieldConfig = FieldConfig> =
  ComponentType<FieldRendererProps<TConfig>>;

Declaring your renderer as FieldRenderer<FieldTypeRegistry["licensePlate"]> narrows props.config to the licensePlate config specifically — not the full union — so config.country is known and typo-checked.

The three props:

PropTypeNotes
namestringThe field name. Passed alongside field.name so you can wire ARIA id/htmlFor without dotted-path normalization concerns.
configResolveConfig<TConfig>The resolved config (see below).
fieldAnyFieldApiTanStack Form's field API. Read field.state.value, call field.handleChange(...) / field.handleBlur(), read field.state.meta.errors.

config is already resolved

By the time your renderer runs, the builder has evaluated all predicates, so ResolveConfig<TConfig> hands you a flattened config:

  • config.visible is gone — a false visibility predicate unmounts the field upstream, so a mounted renderer is always visible.
  • config.disabled and config.required are plain boolean | undefined (never functions). Read them directly.
  • config.options (on choice fields) is the resolved option array, never the predicate form.

That's why the worked example below reads config.disabled and config.required straight through without checking for a function.

Reuse the builder's FieldShell render-prop wrapper from @/components/forms/registry/field-shell for any "single input" field. It composes the shadcn Field + FieldLabel + FieldDescription + FieldError stack and computes the aria-describedby / aria-invalid / aria-required wiring for you, handing it back through a render callback. Built-in renderers like text and select use it too.


Registering it from module.ts

A module contributes the renderer declaratively, the same way it contributes routes or sidebar items — with slotContribution(FORM_SLOTS.FIELD_TYPES, ...):

import {slotContribution} from "@/core/types";
import {FORM_SLOTS} from "@/components/forms/slots";

import type {ModuleDefinition} from "@/core/types";
import type {FormFieldType} from "@/components/forms/slots";

import {LicensePlateField} from "@/modules/_development/forms-playground/custom-types/license-plate-field";


const _developmentModule: ModuleDefinition = {
  id: "_development",
  slots: [
    slotContribution<FormFieldType>(FORM_SLOTS.FIELD_TYPES, {
      type: "licensePlate",
      component: LicensePlateField as FormFieldType["component"],
    }),
  ],
};


export default _developmentModule;

Note the as FormFieldType["component"] cast on component. A renderer typed FieldRenderer<FieldTypeRegistry["licensePlate"]> has a narrow config; component props are contravariant, so it is not directly assignable to the slot's config: unknown component. The cast bridges that gap. It's sound by construction — the runtime payload is exactly the component you exported — but the compiler can't verify it across the slot boundary, so you assert it.


Declaration-merging the type

To make type: "licensePlate" type-safe in FormFields<T>, add a .d.ts that merges your entry into the builder's FieldTypeRegistry interface. Because it's a .d.ts, TypeScript picks it up ambiently — nobody has to import it.

import type {FieldConfigBase} from "@/components/forms";


declare module "@/components/forms" {
  interface FieldTypeRegistry {
    licensePlate: FieldConfigBase & {
      type: "licensePlate";
      country?: "PT" | "ES" | "AO" | "MZ";
    };
  }
}

Extending FieldConfigBase gives your type all the shared config props for free — label, description, placeholder, colSpan, disabled, required, visible, plus the validate / validateAsync field-level validators. Add only the props unique to your type (here, country).

This single merged key does two jobs:

  1. Authoring sideFieldConfig is derived from FieldTypeRegistry, so { type: "licensePlate", country: "PT" } is now a valid entry in FormFields<T> and gets autocomplete.
  2. Rendering sideFieldRenderer<FieldTypeRegistry["licensePlate"]> resolves to a config typed for that exact entry.

Worked example: the licensePlate field

The licensePlate type lives entirely in the _development module and proves the mechanism end to end. Follow the same three steps for any custom type.

Write the renderer

Type it FieldRenderer<FieldTypeRegistry["licensePlate"]>, read state off the AnyFieldApi, and wrap the input in FieldShell for label/description/error chrome.

import {useT} from "@/components/forms/i18n";
import {normalizeFieldErrors} from "@/components/forms/lib/normalize-field-errors";
import {FieldShell} from "@/components/forms/registry/field-shell";

import type {FieldTypeRegistry} from "@/components/forms/types";
import type {FieldRenderer} from "@/components/forms/registry/types";

import {Input} from "@/components/ui/input";
import {LicensePlate} from "@/components/ui/license-plate";


const PLACEHOLDER_BY_COUNTRY: Record<string, string> = {
  PT: "AA-00-AA",
  ES: "0000 AAA",
  AO: "LDA-00-00-XX",
  MZ: "AAA 000 AA",
};


const LicensePlateField: FieldRenderer<FieldTypeRegistry["licensePlate"]> = ({name, config, field}) => {
  const t = useT();
  const value = (field.state.value as string | undefined) ?? "";
  const errors = normalizeFieldErrors(field.state.meta.errors as unknown[]);

  const placeholder =
    (config.placeholder ? t(config.placeholder) : undefined) ??
    (config.country ? PLACEHOLDER_BY_COUNTRY[config.country] : undefined);

  return (
    <FieldShell
      name={name}
      label={config.label}
      description={config.description}
      required={config.required}
      errors={errors}
    >
      {(inputProps) => (
        <div className="space-y-2">
          <Input
            {...inputProps}
            name={name}
            value={value}
            onChange={(event) => field.handleChange(event.target.value.toUpperCase())}
            onBlur={field.handleBlur}
            placeholder={placeholder}
            disabled={config.disabled}
            autoCapitalize="characters"
            autoComplete="off"
            spellCheck={false}
          />
          {value ? (
            <div aria-hidden="true" className="flex items-center">
              <LicensePlate licensePlateNumber={value} country={config.country} size="sm" />
            </div>
          ) : null}
        </div>
      )}
    </FieldShell>
  );
};


export {LicensePlateField};

A few things to note: config.disabled and config.required are read directly because predicates are already resolved; the input uppercases on change so the <LicensePlate> badge's format detection sees its expected input; and normalizeFieldErrors flattens TanStack's raw meta.errors into the FieldShell-shaped error list.

Declaration-merge the type

Add a .d.ts next to the renderer so type: "licensePlate" becomes a known field type:

import type {FieldConfigBase} from "@/components/forms";


declare module "@/components/forms" {
  interface FieldTypeRegistry {
    licensePlate: FieldConfigBase & {
      type: "licensePlate";
      country?: "PT" | "ES" | "AO" | "MZ";
    };
  }
}

Register it in module.ts

Contribute the renderer to FORM_SLOTS.FIELD_TYPES:

slotContribution<FormFieldType>(FORM_SLOTS.FIELD_TYPES, {
  type: "licensePlate",
  component: LicensePlateField as FormFieldType["component"],
})

Use it in a form

The custom type now behaves like any built-in — full autocomplete inside FormFields<typeof schema>:

import {z} from "zod/v4";
import {useFormBuilder} from "@/components/forms";

import type {FormFields} from "@/components/forms";


const schema = z.object({
  plate: z.string().min(4, "Plate must be at least 4 characters"),
});

const fields: FormFields<typeof schema> = {
  plate: {
    type: "licensePlate",
    label: "License plate",
    description: "Try typing a plate — the badge reflects the detected format.",
    country: "PT",
  },
};

function PlateForm() {
  const form = useFormBuilder({
    formId: "custom-field-types",
    schema,
    fields,
    onSubmit: (values) => console.log(values),
  });

  return (
    <form.Provider>
      <form.Fields names={["plate"]} />
      <form.SubmitButton>Save</form.SubmitButton>
    </form.Provider>
  );
}

Resolution and precedence

At render time the builder resolves a field's type through resolveFieldType(type, slotMap). The slotMap is a Map<string, FieldRenderer> projected from all FIELD_TYPES contributions by the useFormFieldTypes hook. Resolution order:

  1. Built-ins win. resolveFieldType checks the built-in renderer map first. A contribution that reuses a built-in name (e.g. type: "text") is silently shadowed by the built-in — you can't override text, only add new types.
  2. Among contributions, lowest order wins. The registry pre-sorts contributions by order ascending (default 50), and the projection keeps the first entry for each type. So if two modules both contribute licensePlate, the one with the smaller order is used.
  3. Otherwise it throws. An unresolved type raises a descriptive error listing the known built-ins and the registered slot types — so a typo'd or unregistered type fails loudly at render rather than silently rendering nothing.

Rebuilding the fields object (for example, to flip the licensePlate field's country when a toggle changes) does not reset form state. The form store is keyed by formId, not by the identity of the fields object — typed values, errors, and the dirty flag survive the rebuild.


Checklist

  • Renderer typed FieldRenderer<FieldTypeRegistry["yourType"]>, reading state off AnyFieldApi.
  • FieldShell (or the shadcn Field stack directly) used for label / description / error / ARIA wiring.
  • A .d.ts declaration-merging yourType into FieldTypeRegistry, extending FieldConfigBase.
  • A slotContribution(FORM_SLOTS.FIELD_TYPES, { type, component }) entry in the module's module.ts, with the as FormFieldType["component"] cast on component.
  • The type string identical across the renderer's generic, the .d.ts key, and the slot contribution.