Skip to content

Type-safe extensible UiSchema #4916

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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions