webiny-api-cms-custom-field-type

$npx mdskill add webiny/webiny-js/webiny-api-cms-custom-field-type

Build custom CMS fields with fluent builders and TypeScript support.

  • Creates storage formats and validation logic missing from built-in types.
  • Extends DataFieldBuilder and registers factories via dependency injection.
  • Adds module augmentation for TypeScript autocomplete on the fields registry.
  • Enables fluent builder API usage in ModelFactory implementations.
SKILL.md
.github/skills/webiny-api-cms-custom-field-typeView on GitHub ↗
---
name: webiny-api-cms-custom-field-type
context: webiny-extensions
description: >
  How to implement a custom CMS field type that integrates with the model builder's
  fluent API. Covers extending DataFieldBuilder, composing validator interfaces,
  creating a FieldTypeFactory, registering via DI, and module augmentation for
  TypeScript autocomplete on the fields() registry.
---

# Custom CMS Field Type

## TL;DR

A custom field type is a class that extends `DataFieldBuilder<"yourType">`, paired with a factory class implementing `FieldType.Factory`. Register the factory with `container.register(YourFieldType)`. Add a module augmentation on `"webiny/api/cms/model"` so the `fields` registry method gets TypeScript autocomplete.

## When to Use This

Use a custom field type when:

- You need a field with a storage format or validation logic not covered by the built-in types (`text`, `number`, `boolean`, `datetime`, `file`, `ref`, `object`, `richText`, `longText`, `json`, `dynamicZone`)
- You want to expose a fluent builder API (e.g., `fields.slug()`, `fields.color()`) in `ModelFactory` implementations

## Field Type Structure

A custom field type consists of three parts:

1. **Builder interface** — extends `DataFieldBuilder<"type">` plus `FieldTypeValidator.*` types
2. **Builder class** — implements the interface, calls `this.validation()` for each validator
3. **Factory class** — implements `FieldType.Factory`, creates builder instances

As a standalone extension (not part of a larger feature), the directory layout is:

```
extensions/
└── SlugFieldType/
    ├── SlugFieldType.ts     # builder interface, builder class, factory class
    └── feature.ts           # createFeature — registers the factory into the DI container
```

`feature.ts`:

```ts
// extensions/SlugFieldType/feature.ts
import { createFeature } from "webiny/api";
import { SlugFieldType } from "./SlugFieldType.js";

export const SlugFieldTypeFeature = createFeature({
  name: "SlugFieldType",
  register(container) {
    container.register(SlugFieldType);
  }
});
```

Register in the API entry point:

```ts
// api/Extension.ts
import { createFeature } from "webiny/api";
import { SlugFieldTypeFeature } from "~/extensions/SlugFieldType/feature.js";

export const Extension = createFeature({
  name: "MyExtension",
  register(container) {
    SlugFieldTypeFeature.register(container);
  }
});
```

## Complete Example

```ts
// extensions/SlugFieldType/SlugFieldType.ts
import { DataFieldBuilder, FieldType } from "webiny/api/cms/model";
import type { FieldTypeValidator } from "webiny/api/cms/model";

// 1. Builder interface — extends DataFieldBuilder + desired FieldTypeValidator types
export interface ISlugFieldBuilder
  extends
    DataFieldBuilder<"slug">,
    FieldTypeValidator.Required,
    FieldTypeValidator.Pattern,
    FieldTypeValidator.Unique {}

// 2. Module augmentation — adds fields.slug() to the registry
declare module "webiny/api/cms/model" {
  interface IFieldBuilderRegistry {
    slug(): ISlugFieldBuilder;
  }

  interface IFieldRendererRegistry {
    myCustomRenderer: {
      fieldType: "text" | "number";
      settings: undefined;
    };
  }
}

// 3. Builder class — implements each validator method via this.validation()
class SlugFieldBuilder extends DataFieldBuilder<"slug"> implements ISlugFieldBuilder {
  constructor() {
    super("slug");
  }

  required(message?: string): this {
    return this.validation({
      name: "required",
      message: message || "Value is required.",
      settings: {}
    });
  }

  pattern(regex: string, flags = "", message?: string): this {
    return this.validation({
      name: "pattern",
      message: message || "Invalid value.",
      settings: { preset: "custom", regex, flags }
    });
  }

  unique(message?: string): this {
    return this.validation({
      name: "unique",
      message: message || "Value must be unique.",
      settings: {}
    });
  }
}

// 4. Factory class — implements FieldType.Factory
class SlugFieldTypeFactory implements FieldType.Factory {
  readonly type = "slug";

  create(): ISlugFieldBuilder {
    return new SlugFieldBuilder();
  }
}

// 5. Export as a FieldType implementation
export const SlugFieldType = FieldType.createImplementation({
  implementation: SlugFieldTypeFactory,
  dependencies: []
});
```

## Using the Custom Field in a Model

After registration, `fields.slug()` is available in any `ModelFactory` implementation:

```ts
import { ModelFactory } from "webiny/api/cms/model";

class ProductModelImpl implements ModelFactory.Interface {
  async execute(builder: ModelFactory.Builder) {
    return [
      builder
        .public({ modelId: "product", name: "Product", group: "ungrouped" })
        .fields(fields => ({
          name: fields.text().label("Name").required(),
          slug: fields
            .slug()
            .label("Slug")
            .required("Slug is required.")
            .unique()
            .pattern("^[a-z0-9-]+$", "", "Only lowercase letters, numbers, and hyphens.")
        }))
        .layout([["name", "slug"]])
        .titleFieldId("name")
        .singularApiName("Product")
        .pluralApiName("Products")
    ];
  }
}
```

## DataFieldBuilder API

All methods return `this` for chaining.

| Method                      | Description                                   |
| --------------------------- | --------------------------------------------- |
| `label(text)`               | Field label shown in the Admin editor         |
| `help(text)`                | Help text shown below the field               |
| `description(text)`         | Field description                             |
| `fieldId(id)`               | Override the auto-derived field ID            |
| `storageId(id)`             | Override the storage identifier               |
| `placeholder(text)`         | Placeholder text for the input                |
| `defaultValue(value)`       | Default value for new entries                 |
| `list()`                    | Make the field accept multiple values (array) |
| `listMinLength(n, msg?)`    | Minimum number of list items                  |
| `listMaxLength(n, msg?)`    | Maximum number of list items                  |
| `tags(tags)`                | Arbitrary tags for filtering/querying         |
| `renderer(name, settings?)` | Set the Admin UI renderer                     |
| `settings(settings)`        | Set arbitrary field settings                  |

### Protected Methods (for use inside validator implementations only)

| Method                      | Description                                                        |
| --------------------------- | ------------------------------------------------------------------ |
| `this.validation(rule)`     | Append a `CmsModelFieldValidation` to the field's validation array |
| `this.listValidation(rule)` | Append a `CmsModelFieldValidation` to the list validation array    |

A `CmsModelFieldValidation` has the shape:

```ts
{
  name: string; // validator name (e.g., "required", "minLength", "pattern")
  message: string; // error message shown to the user
  settings: Record<string, any>; // validator-specific config
}
```

## Available Validators

Import via `import type { FieldTypeValidator } from "webiny/api/cms/model"` and extend your builder interface with them. Each type adds one method to your interface:

| Type                                | Method signature                   |
| ----------------------------------- | ---------------------------------- |
| `FieldTypeValidator.Required`       | `required(message?)`               |
| `FieldTypeValidator.Unique`         | `unique(message?)`                 |
| `FieldTypeValidator.MinLength`      | `minLength(value, message?)`       |
| `FieldTypeValidator.MaxLength`      | `maxLength(value, message?)`       |
| `FieldTypeValidator.Pattern`        | `pattern(regex, flags?, message?)` |
| `FieldTypeValidator.Email`          | `email(message?)`                  |
| `FieldTypeValidator.Url`            | `url(message?)`                    |
| `FieldTypeValidator.LowerCase`      | `lowerCase(message?)`              |
| `FieldTypeValidator.UpperCase`      | `upperCase(message?)`              |
| `FieldTypeValidator.LowerCaseSpace` | `lowerCaseSpace(message?)`         |
| `FieldTypeValidator.UpperCaseSpace` | `upperCaseSpace(message?)`         |
| `FieldTypeValidator.Gte`            | `gte(value, message?)`             |
| `FieldTypeValidator.Lte`            | `lte(value, message?)`             |
| `FieldTypeValidator.DateGte`        | `dateGte(value, message?)`         |
| `FieldTypeValidator.DateLte`        | `dateLte(value, message?)`         |
| `FieldTypeValidator.ListMinLength`  | `listMinLength(value, message?)`   |
| `FieldTypeValidator.ListMaxLength`  | `listMaxLength(value, message?)`   |

When implementing a validator method in the builder class, call `this.validation()` with the appropriate `name` and `settings`. For `ListMinLength`/`ListMaxLength`, call `this.listValidation()` instead. The `settings` shapes:

| Validator                    | `name`                        | `settings`                                                                   |
| ---------------------------- | ----------------------------- | ---------------------------------------------------------------------------- |
| Required, Unique             | `"required"` / `"unique"`     | `{}`                                                                         |
| MinLength, MaxLength         | `"minLength"` / `"maxLength"` | `{ value: String(n) }`                                                       |
| Gte, Lte                     | `"gte"` / `"lte"`             | `{ value: String(n) }`                                                       |
| DateGte, DateLte             | `"dateGte"` / `"dateLte"`     | `{ value }`                                                                  |
| Pattern                      | `"pattern"`                   | `{ preset: "custom", regex, flags }`                                         |
| Email                        | `"pattern"`                   | `{ preset: "email", regex: null, flags: null }`                              |
| Url                          | `"pattern"`                   | `{ preset: "url", regex: null, flags: null }`                                |
| LowerCase / UpperCase / etc. | `"pattern"`                   | `{ preset: "lowerCase"` / `"upperCase"` / etc., `regex: null, flags: null }` |

## Key Rules

1. **`type` string must be unique** — the factory's `readonly type` must not collide with any built-in type (`text`, `number`, `boolean`, `datetime`, `file`, `ref`, `object`, `richText`, `longText`, `json`, `dynamicZone`) or other custom types.
2. **Module augmentation target** — augment `"webiny/api/cms/model"` using `namespace FieldBuilderRegistry { interface Interface { yourType(): IYourFieldBuilder; } }`.
3. **`validation()` is protected** — never call it from outside the builder class. Expose validators as named methods on the interface (e.g., `required()`, `minLength()`).
4. **`dependencies: []`** — field type factories have no DI dependencies; always pass an empty array.
5. **Registration order** — register custom `FieldType` implementations before `FieldBuilderRegistry` is resolved (i.e., in the same `register()` call or before it runs). The registry collects all `FieldType` instances at construction time.

## Related Skills

- **webiny-api-cms-content-models** — Using the model builder's fluent API to define CMS models
- **webiny-api-cms-catalog** — Full catalog of CMS abstractions including `ModelFactory`, `FieldType`, `DataFieldBuilder`
- **webiny-dependency-injection** — The `createImplementation` pattern and DI scoping
More from webiny/webiny-js