Skip to content

Type-safe extensible UiSchema #4916

@kolodny

Description

@kolodny

Prerequisites

What theme are you using?

core

Is your feature request related to a problem? Please describe.

Currently, UiSchema is loosely typed. Any widget can be assigned to any field, and there's no autocomplete or validation for type-specific options like minValue (numbers) or placeholder (strings).

// Current behavior - no type errors, no autocomplete
const uiSchema: UiSchema = {
  name: { 'ui:widget': 'RangeWidget' },  // Wrong! RangeWidget is for numbers
  age: { 'ui:placeholder': 'Enter age' }, // Wrong! placeholder is for strings
};

Describe the solution you'd like

Core Pattern: { when, then }

TS Playground to see implementation

Define type-specific options using a simple pattern:

type BaseChecks =
  | { when: unknown[]; then: { widget?: 'CheckboxesWidget'; addable?: boolean; orderable?: boolean; removable?: boolean } }
  | { when: number; then: { widget?: 'TextWidget' | 'RangeWidget' | 'UpDownWidget'; minValue?: number; maxValue?: number; step?: number } }
  | { when: string; then: { widget?: 'TextWidget' | 'TextareaWidget' | 'PasswordWidget'; placeholder?: string } }
  | { when: boolean; then: { widget?: 'CheckboxWidget' | 'RadioWidget' } }
  | { when: object & { length?: never }; then: { widget?: 'ObjectField'; collapsible?: boolean; defaultExpanded?: boolean } };

Main Type

// T defaults to any for vanilla usage without type param
type UiOptions<T = any, Checks = never> =
  CommonOpts &
  ComponentOpts<T, BaseChecks | Checks> &
  WithUiPrefix<RawOptsFor<T, BaseChecks | Checks>> &
  (T extends (infer U)[] ? { items?: UiOptions<U, Checks> } : {}) &
  (T extends unknown[] ? {} : T extends object ? {
    'ui:order'?: (keyof T | '*')[];
  } & { [K in keyof T]?: UiOptions<T[K], Checks> } : {});

Theme Extension

Themes can add custom widgets scoped to specific types:

// Theme adds ToggleWidget for booleans only
type MyThemeChecks =
  | { when: boolean; then: { widget?: 'ToggleWidget' } }
  | { when: string[]; then: { widget?: 'CommaSeparatedWidget' } }
  | { when: number; then: { widget?: 'SliderWidget' } };

type MyThemeUiOptions<T = any, Checks = never> = UiOptions<T, MyThemeChecks | Checks>;

User Extension

Users can add domain-specific options:

type MyChecks =
  | { when: { isUser: true }; then: { admin?: boolean; widget?: 'UserWidget' } }
  | { when: { isProduct: true }; then: { showPrice?: boolean } };

type MyUiOptions<T = any, Checks = never> = MyThemeUiOptions<T, MyChecks | Checks>;

Usage Examples

interface FormData {
  age: number;
  active: boolean;
  tags: string[];
  user: { name: string; isUser: true };
}

const uiSchema: MyUiOptions<FormData> = {
  // Type-safe field ordering
  'ui:order': ['active', 'age', 'tags', 'user', '*'],

  // number gets: RangeWidget, SliderWidget, minValue, maxValue, step
  age: { 'ui:widget': 'SliderWidget', 'ui:minValue': 0 },

  // boolean gets: CheckboxWidget, RadioWidget, ToggleWidget (from theme)
  active: { 'ui:widget': 'ToggleWidget' },

  // string[] gets: CheckboxesWidget, CommaSeparatedWidget (from theme), addable, etc.
  tags: { 'ui:widget': 'CommaSeparatedWidget' },

  // Objects matching { isUser: true } get admin option
  user: {
    'ui:admin': true,
    name: { 'ui:placeholder': 'Enter name' },
  },
};

// Type errors caught at compile time:
const bad: MyUiOptions<{ name: string }> = {
  name: { 'ui:widget': 'RangeWidget' },  // Error: RangeWidget not valid for string
};

Key Design Decisions

Widgets use Union, Options use Intersection

When multiple checks match (e.g., number matches both base and theme checks):

  • Widgets: Combined as union (all valid widgets available)
  • Options: Combined as intersection (all options merged)
// number matches BaseChecks AND ThemeChecks
// Widget options: 'TextWidget' | 'RangeWidget' | 'UpDownWidget' | 'SliderWidget'
// Options merged: { minValue?: number; maxValue?: number; step?: number }

Both Formats Supported

// ui:widget format
{ 'ui:widget': 'ToggleWidget' }

// ui:options format
{ 'ui:options': { widget: 'ToggleWidget' } }

// ui:prefixed options
{ 'ui:minValue': 0, 'ui:maxValue': 100 }

// Inside ui:options
{ 'ui:options': { minValue: 0, maxValue: 100 } }

Extensibility Chain

Each layer can pass through the Checks parameter:

UiOptions<T, Checks>
    ↓ adds ThemeChecks
ThemeUiOptions<T, Checks>
    ↓ adds UserChecks
UserUiOptions<T, Checks>
    ↓ further extensible...

Implementation

Full implementation (~60 lines)
// Type-specific checks using { when, then } pattern
type BaseChecks =
  | { when: unknown[]; then: { widget?: 'CheckboxesWidget'; field?: 'ArrayField'; addable?: boolean; orderable?: boolean; removable?: boolean } }
  | { when: number; then: { widget?: 'TextWidget' | 'RangeWidget' | 'UpDownWidget'; minValue?: number; maxValue?: number; step?: number } }
  | { when: string; then: { widget?: 'TextWidget' | 'TextareaWidget' | 'PasswordWidget'; field?: 'StringField'; placeholder?: string } }
  | { when: boolean; then: { widget?: 'CheckboxWidget' | 'RadioWidget'; field?: 'BooleanField' } }
  | { when: object & { length?: never }; then: { widget?: 'ObjectField'; field?: 'ObjectField'; collapsible?: boolean; defaultExpanded?: boolean } };

// Options available on all field types
type CommonOpts = {
  'ui:help'?: string;
  'ui:readonly'?: boolean;
  'ui:disabled'?: boolean;
  'ui:classNames'?: string;
  'ui:autofocus'?: boolean;
};

// --- Internal utility types ---

type UnionToIntersection<U> =
  (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never;

type WidgetsFor<T, Checks> = Checks extends { when: infer M; then: { widget?: infer W } }
  ? T extends M ? W : never
  : never;

type FieldsFor<T, Checks> = Checks extends { when: infer M; then: { field?: infer F } }
  ? T extends M ? F : never
  : never;

type RawOptsFor<T, Checks> = UnionToIntersection<
  Checks extends { when: infer M; then: infer O }
    ? T extends M ? Omit<O, 'widget' | 'field'> : never
    : never
>;

type WithUiPrefix<T> = {
  [K in keyof T as K extends string ? `ui:${K}` : never]?: T[K];
};

type ComponentOpts<T, Checks> = {
  'ui:widget'?: WidgetsFor<T, Checks>;
  'ui:field'?: FieldsFor<T, Checks>;
  'ui:options'?: { widget?: WidgetsFor<T, Checks>; field?: FieldsFor<T, Checks> } & RawOptsFor<T, Checks>;
};

// --- Main exported type ---

// T defaults to any for vanilla usage without type param
type UiOptions<T = any, Checks = never> =
  CommonOpts &
  ComponentOpts<T, BaseChecks | Checks> &
  WithUiPrefix<RawOptsFor<T, BaseChecks | Checks>> &
  (T extends (infer U)[] ? { items?: UiOptions<U, Checks> } : {}) &
  (T extends unknown[] ? {} : T extends object ? {
    'ui:order'?: (keyof T | '*')[];
  } & { [K in keyof T]?: UiOptions<T[K], Checks> } : {});

Vanilla Usage (No Type Parameter)

All types default T to any, so you can use them without specifying a type:

// Works without type parameter - any nested structure allowed
const simpleSchema: MyThemeUiOptions = {
  foo: {
    bar: {
      baz: { 'ui:widget': 'CommaSeparatedWidget' },
    },
  },
};

// Or with explicit type for full type safety
const typedSchema: MyThemeUiOptions<{ name: string; age: number }> = {
  name: { 'ui:placeholder': 'Enter name' },
  age: { 'ui:widget': 'RangeWidget' },
};

Known Limitations

  • Record<string, T>: Index signatures conflict with ui: prefixed options. Use explicit object types instead.
  • Typos in { when, then }: Typos like wigdet instead of widget won't be caught at definition time (only at usage).

Benefits

  1. Type safety: Wrong widgets/options caught at compile time
  2. Autocomplete: IDE suggests valid widgets and options based on field type
  3. Theme extensibility: Themes add widgets scoped to specific types
  4. User extensibility: Users add domain-specific options via marker types
  5. Backward compatible: Can be adopted incrementally alongside existing UiSchema

Questions for Maintainers

  1. Would this fit better in @rjsf/utils or as a separate package?
  2. Should BaseChecks include all standard widgets or start minimal?
  3. Any concerns about the { when, then } naming convention?

Describe alternatives you've considered

No response

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions