Skip to content

Type-checked paths

Eufemia Forms uses JSON Pointer strings (such as /firstName) for the path prop. By default these are plain strings, so a typo like /firstNme is not caught until runtime.

Register your data type once, and every Field.* and Value.* gets path autocomplete, while Form.Handler keeps its typed data/defaultData. Opt into the pre-typed namespaces to turn typos into hard compile-time errors.

Good to know

  • The typing is purely type-level and is erased at build time, so the types themselves add no runtime or bundle-size cost.
  • Nested objects and arrays are supported, e.g. /address/street or /items/0/title.
  • The exported Paths<Data> helper produces the union of all valid paths for a data type, and PathValue<Data, Path> resolves the value type at a given path – both are available for advanced use.
  • The registered data type is available as RegisteredFormData, handy for typing your own helpers against the same shape, e.g. function onSubmit(data: RegisteredFormData) {}.
  • The plain path prop is typed against the root data type. The section-relative path inside Form.Section is not narrowed on the registered root namespace – use TypedSectionField (or the regular Field/Value, which accept any string) there. For the item-relative itemPath inside Iterate, use TypedItemField.
  • A path stored in a const keeps its literal type (e.g. '/company'), so it is type-checked exactly like an inline string. Use const, not letlet widens the value to string and loses the narrowing. The same const can drive the type helpers with typeof, e.g. TypedSectionField<typeof path>, keeping the section type and the path prop in sync.
  • See also the TypeScript support section in Getting started.

Register the data type once

Register your data type globally. Augment the Register interface once:

// forms-register.ts – imported once (e.g. from your app entry)
import type { MyData } from './types'
declare module '@dnb/eufemia/extensions/forms' {
interface Register {
formData: MyData
}
}

With the registration in place, the path prop on every Field.* and Value.* autocompletes the valid paths directly:

import { Field, Form } from '@dnb/eufemia/extensions/forms'
function MyForm() {
return (
<Form.Handler defaultData={{ firstName: 'Nora', age: 30 }}>
<Form.Card>
{/* Autocompletes /firstName, /age, /address/street … */}
<Field.Name path="/firstName" />
<Field.Number path="/age" />
</Form.Card>
</Form.Handler>
)
}

This is non-breaking: any string is still accepted, so relative paths inside Form.Section and Iterate, as well as dynamic paths, keep working. Because of that, a typo like path="/unknown" is not a hard error on the plain Field/Value – they get autocomplete, not compile-time errors.

Pre-typed namespaces

To turn typos into compile-time errors, import the pre-typed namespaces (e.g. RegisteredForm) and alias them to Form/Field/Value/Iterate. They resolve the registered data type, so bare namespace access is fully type-checked:

  • RegisteredForm
  • RegisteredField
  • RegisteredValue
  • RegisteredIterate

This relies on the declare module registration being set – without it, the namespaces fall back to the default and accept any string instead of your data paths.

import {
RegisteredForm as Form,
RegisteredField as Field,
} from '@dnb/eufemia/extensions/forms'
function MyForm() {
return (
<Form.Handler defaultData={{ firstName: 'Nora', age: 30 }}>
<Form.Card>
<Field.Name.First path="/firstName" />
<Field.Name.Company path="/company/name" />
{/* Type error: "/firstLast" is not a path in the registered data */}
<Field.Name.Last path="/firstLast" />
</Form.Card>
</Form.Handler>
)
}
<Form.Handler
  defaultData={{
    firstName: 'Nora',
    age: 30,
  }}
>
  <Form.Card>
    <Field.Name.First path="/firstName" />
    <Field.Number path="/age" label="Age" />
    <Field.Address.Street path="/address/street" />
    <Field.Name.Company path="/company/name" />

    {/* @ts-expect-error /lastName is not defined */}
    <Field.Name.Last path="/lastName" />

    <Form.SubmitButton />
  </Form.Card>
</Form.Handler>

The path is checked against the root data type, so absolute paths like /company/name are typed and typos are rejected. The section-relative path inside Form.Section (such as /name for company.name) and the item-relative itemPath inside Iterate are not narrowed by the registered root namespace – but both can be typed by deriving the nested type from the same registered root, with TypedSectionField and TypedItemField.

This binds one data type globally.

Type-checking container paths

When you use the pre-typed RegisteredForm and RegisteredIterate namespaces, the path on the containers themselves is narrowed too: Form.Section only accepts paths that point to an object, and Iterate.Array only accepts paths that point to an array. A typo like path="/accountss" is therefore a compile-time error.

Type-checking itemPath inside Iterate

Inside an Iterate.Array, fields address each item with the item-relative itemPath prop. Its valid paths come from the array item type, not the root, so the registered root namespace leaves it as a plain string. You do not need a second registration: derive the item type from the single registered root with TypedItemField, passing the same path you give to Iterate.Array.

import {
RegisteredField as Field,
RegisteredIterate as Iterate,
} from '@dnb/eufemia/extensions/forms'
import type { TypedItemField } from '@dnb/eufemia/extensions/forms'
// Registered data: { accounts: Array<{ name: string; balance: number }> }
const { String: StringItem, Number: NumberItem } =
Field as TypedItemField<'/accounts'>
function MyForm() {
return (
// The container path is checked too: "/accounts" must be an array path
<Iterate.Array path="/accounts">
<StringItem itemPath="/name" />
<NumberItem itemPath="/balance" />
{/* Type error: "/nope" is not a path in the array item */}
<StringItem itemPath="/nope" />
</Iterate.Array>
)
}

TypedItemValue narrows the Value namespace the same way. Because a single global Register binds one root data type, every nested object and array-item type is reachable from the one root – via TypedItemField for itemPath and the PathValue helper for deriving a sub-type.

// Derive the item type from the registered array path to type-check
// the item-relative `itemPath` — no extra registration.
const { String: StringItem, Number: NumberItem } =
  Field as TypedItemField<'/accounts'>
render(
  <Form.Handler
    defaultData={{
      accounts: [
        {
          name: 'Savings',
          balance: 1000,
        },
        {
          name: 'Checking',
          balance: 500,
        },
      ],
    }}
  >
    <Form.Card>
      <Iterate.Array path="/accounts">
        <StringItem itemPath="/name" label="Name" />
        <NumberItem itemPath="/balance" label="Balance" />

        {/* @ts-expect-error /nope is not a path in the array item */}
        <StringItem itemPath="/nope" />
      </Iterate.Array>

      <Form.SubmitButton />
    </Form.Card>
  </Form.Handler>
)

Type-checking section-relative paths

Inside a Form.Section, fields address values with a path relative to the section – the section path is prefixed automatically. Those relative paths come from the section's object type, not the root, so the registered root namespace does not narrow them. As with Iterate, you do not need a second registration: derive the section type from the single registered root with TypedSectionField, passing the same path you give to Form.Section.

import {
RegisteredField as Field,
RegisteredForm as Form,
} from '@dnb/eufemia/extensions/forms'
import type { TypedSectionField } from '@dnb/eufemia/extensions/forms'
// Registered data: { company?: { name: string } }
// `as unknown as` because we re-narrow the already-root-typed namespace.
const { Name } = Field as unknown as TypedSectionField<'/company'>
function MyForm() {
return (
// The container path is checked too: "/company" must be an object path
<Form.Section path="/company">
{/* Checked against the section type, relative to /company */}
<Name.Company path="/name" />
{/* Type error: "/nope" is not a path in the section object */}
<Name.Company path="/nope" />
</Form.Section>
)
}

TypedSectionValue narrows the Value namespace the same way. This is the same single-registered-root model as TypedItemField: the section's type is resolved from the one registered root, so there is a single source of truth.

// Derive the section's object type from the registered path to
// type-check the section-relative `path` — no extra registration.
// `as unknown as` because we re-narrow the already-root-typed namespace.
const { Name } = Field as unknown as TypedSectionField<'/company'>
render(
  <Form.Handler
    defaultData={{
      company: {
        name: 'DNB',
      },
    }}
  >
    <Form.Card>
      <Form.Section path="/company">
        {/* Section-relative paths are checked against the section type */}
        <Name.Company path="/name" />

        {/* @ts-expect-error /nope is not a path in the section */}
        <Name.Company path="/nope" />
      </Form.Section>

      <Form.SubmitButton />
    </Form.Card>
  </Form.Handler>
)