Handsontable Cell Type Development
Structure
Cell types are composition objects, not classes. They bundle an editor, renderer, and validator under a single name:
export const MyCellType = {
CELL_TYPE: 'myType',
editor: MyEditor,
renderer: myRenderer,
validator: myValidator,
// Optional:
valueSetter: customSetter,
valueGetter: customGetter,
valueFormatter: customFormatter,
dataType: 'myType',
};
When a column or cell sets type: 'myType', Handsontable applies all composed components automatically.
File structure
src/cellTypes/{typeName}/
{typeName}.ts # Cell type object
index.ts # Re-exports
Registry: src/cellTypes/registry.ts.
Registration
import { registerCellType } from '../../cellTypes/registry';
registerCellType(MyCellType);
Also export from src/cellTypes/index.ts so the type is available in the full bundle.
Integration with metaSchema
New cell types must be added to src/dataMap/metaManager/metaSchema.ts so Handsontable recognizes the type name in configuration. Add the type string to the type option's accepted values.
Key rules
- Think of cell types as pre-configured bundles. They exist for convenience - users set one
type instead of specifying editor, renderer, and validator separately.
- All components are optional. A cell type can omit
validator if no validation is needed, or omit editor for read-only display types.
- Individual overrides win. If a user sets both
type: 'myType' and renderer: customRenderer, the explicit renderer takes precedence over the one from the cell type.
valueSetter is the ONLY place a type may normalize an incoming value — never the editor alone, and never a plugin. A value reaches a cell by many routes, and the editor is only one of them: a paste, setDataAtCell(), populateFromArray(), autofill and undo all bypass it. valueSetter runs on every one of those, so a type whose stored shape differs from what the user writes (a key/value source, a complex-format type) must resolve it there. Two rules come with that, both learned from DEV-57, where the autocomplete editor resolved a typed label against source while nothing else did — a pasted label was stored as a bare string among key/value objects, and a strict dropdown then marked the cell invalid:
- Share the rule with the editor, do not copy it. The single implementation is
findChoiceByDisplayedValue() (utils/cellSource.ts), called by both autocompleteEditor#getValue() and the autocomplete valueSetter. Two copies of one matching rule is exactly what let those paths drift.
- Anything exported from
src/helpers/** is public API forever, types included. index.ts spreads those modules onto Handsontable.helper and base.ts types the namespace as typeof import('./helpers/object'), so a new export there is a permanent maintenance commitment and a narrowed signature is a break. That is why utils/cellSource.ts holds the whole key/value rule — including isKeyValueEntry(), the narrowing form of the public isKeyValueObject(). It delegates to the public function rather than repeating the shape test, so the two cannot disagree, and helpers/object.ts keeps a zero diff. Reach for src/utils/ for anything a cell type needs.
valueSetter takes five arguments: (value, visualRow, visualCol, cellMeta, source). utils/valueAccessors.ts passes all five, so cellMeta.source, cellMeta.allowHtml and the change source need no plumbing. Type the meta parameter as a Pick<CellProperties, …> of the fields you read, so a unit test need not build a whole meta object; read anything else through this.getCellMetaTransient, never this.getCellMeta (see the core AGENTS.md). The source parameter is declared optional on the public type on purpose — a required fifth parameter would raise the option's minimum call arity and break a consumer that reads the option back out and calls it with four (.ai/BREAKING-CHANGES.md).
- Never write a delegating setter by hand — re-export.
dropdownType/accessors/valueSetter.ts used to be a hand-written delegate, and it dropped cellMeta, which left the strict column — the one where the bug is visible — unfixed while the non-strict one worked. It is now export { valueSetter } from '../../autocompleteType/accessors';: a re-export has no argument list to keep in sync, so that class of mistake is gone rather than documented. A unit test pins the identity (DropdownCellType.valueSetter is AutocompleteCellType.valueSetter).
- Skip every transformation on
UndoRedo.*. utils/valueAccessors.ts states the invariant — undo and redo restore what the cell held before, verbatim — and honors it for emptyValue. The autocomplete setter did not: it wrapped a restored plain label as { key: <label>, value: <label> } whenever the cell happened to hold an entry, so undoing a column loaded with plain labels produced a fabricated pair a strict column then rejected. Return newValue untouched when source starts with 'UndoRedo.'.
- Guard an empty write.
isEmpty(newValue) must skip any resolution, or a source entry carrying an empty label stands in for "no value" and allowEmpty stops meaning what it says.
- Gate the expensive part on a cheap shape check. The setter runs once per changed cell, so a paste of thousands of rows multiplies whatever it does.
hasKeyValueChoices() reads only the entries' shape — no string work — so a column whose source holds plain strings never pays for a label scan it could not use. Do not memoize the scan itself: a source array can be mutated in place by the host application, and a stale displayed-text map would resolve a label to an option no longer offered.
- The gate bounds who pays, not how much. A column that does hold key/value entries still runs
findChoiceByDisplayedValue() per changed cell, and that is a linear scan which calls stringify() and stripTags() on every choice it walks — stripTags() reads the label character by character. Cost is therefore changed cells × source size. At realistic dropdown sizes (10–100 options) a 10k-row paste stays in single-digit milliseconds, but a source in the hundreds-to-thousands turns the same paste into roughly a second of scanning. That is the accepted price of never serving a stale option; if a source that large ever needs to be fast, the fix is a map invalidated by identity, not a plain cache.
Reference implementations
src/cellTypes/numericType/numericType.ts - Composes numeric editor, renderer, and validator.
src/cellTypes/textType/textType.ts - Simplest type, good starting template.
src/cellTypes/dateType/dateType.ts - Date handling with format options.
src/cellTypes/checkboxType/checkboxType.ts - Boolean toggle pattern.
Common mistakes
- Forgetting to register the cell type in
src/cellTypes/registry.ts.
- Not adding the type to
metaSchema.ts, causing Handsontable to ignore the type name.
- Duplicating editor/renderer/validator logic instead of importing existing components.
- Not exporting from
src/cellTypes/index.ts for the full bundle.
1---2name: handsontable-celltype-dev3description: Use when creating or modifying a Handsontable cell type that composes an editor, renderer, and validator into a reusable configuration object registered by name4---56# Handsontable Cell Type Development78## Structure910Cell types are **composition objects**, not classes. They bundle an editor, renderer, and validator under a single name:1112```js13export const MyCellType = {14 CELL_TYPE: 'myType',15 editor: MyEditor,16 renderer: myRenderer,17 validator: myValidator,18 // Optional:19 valueSetter: customSetter,20 valueGetter: customGetter,21 valueFormatter: customFormatter,22 dataType: 'myType',23};24```2526When a column or cell sets `type: 'myType'`, Handsontable applies all composed components automatically.2728## File structure2930```31src/cellTypes/{typeName}/32 {typeName}.ts # Cell type object33 index.ts # Re-exports34```3536Registry: `src/cellTypes/registry.ts`.3738## Registration3940```js41import { registerCellType } from '../../cellTypes/registry';42registerCellType(MyCellType);43```4445Also export from `src/cellTypes/index.ts` so the type is available in the full bundle.4647## Integration with metaSchema4849New cell types must be added to `src/dataMap/metaManager/metaSchema.ts` so Handsontable recognizes the type name in configuration. Add the type string to the `type` option's accepted values.5051## Key rules5253- **Think of cell types as pre-configured bundles.** They exist for convenience - users set one `type` instead of specifying `editor`, `renderer`, and `validator` separately.54- **All components are optional.** A cell type can omit `validator` if no validation is needed, or omit `editor` for read-only display types.55- **Individual overrides win.** If a user sets both `type: 'myType'` and `renderer: customRenderer`, the explicit `renderer` takes precedence over the one from the cell type.56- **`valueSetter` is the ONLY place a type may normalize an incoming value — never the editor alone, and never a plugin.** A value reaches a cell by many routes, and the editor is only one of them: a paste, `setDataAtCell()`, `populateFromArray()`, autofill and undo all bypass it. `valueSetter` runs on every one of those, so a type whose stored shape differs from what the user writes (a key/value `source`, a complex-format type) must resolve it there. Two rules come with that, both learned from DEV-57, where the autocomplete editor resolved a typed label against `source` while nothing else did — a pasted label was stored as a bare string among key/value objects, and a `strict` `dropdown` then marked the cell invalid:57 - **Share the rule with the editor, do not copy it.** The single implementation is `findChoiceByDisplayedValue()` (`utils/cellSource.ts`), called by both `autocompleteEditor#getValue()` and the autocomplete `valueSetter`. Two copies of one matching rule is exactly what let those paths drift.58 - **Anything exported from `src/helpers/**` is public API forever, types included.** `index.ts` spreads those modules onto `Handsontable.helper` and `base.ts` types the namespace as `typeof import('./helpers/object')`, so a new export there is a permanent maintenance commitment and a narrowed signature is a break. That is why `utils/cellSource.ts` holds the whole key/value rule — including `isKeyValueEntry()`, the narrowing form of the public `isKeyValueObject()`. It **delegates** to the public function rather than repeating the shape test, so the two cannot disagree, and `helpers/object.ts` keeps a zero diff. Reach for `src/utils/` for anything a cell type needs.59 - **`valueSetter` takes five arguments: `(value, visualRow, visualCol, cellMeta, source)`.** `utils/valueAccessors.ts` passes all five, so `cellMeta.source`, `cellMeta.allowHtml` and the change source need no plumbing. Type the meta parameter as a `Pick<CellProperties, …>` of the fields you read, so a unit test need not build a whole meta object; read anything else through `this.getCellMetaTransient`, never `this.getCellMeta` (see the core `AGENTS.md`). The `source` parameter is declared **optional** on the public type on purpose — a required fifth parameter would raise the option's minimum call arity and break a consumer that reads the option back out and calls it with four (`.ai/BREAKING-CHANGES.md`).60 - **Never write a delegating setter by hand — re-export.** `dropdownType/accessors/valueSetter.ts` used to be a hand-written delegate, and it dropped `cellMeta`, which left the strict column — the one where the bug is visible — unfixed while the non-strict one worked. It is now `export { valueSetter } from '../../autocompleteType/accessors';`: a re-export has no argument list to keep in sync, so that class of mistake is gone rather than documented. A unit test pins the identity (`DropdownCellType.valueSetter` is `AutocompleteCellType.valueSetter`).61 - **Skip every transformation on `UndoRedo.*`.** `utils/valueAccessors.ts` states the invariant — undo and redo restore what the cell held before, verbatim — and honors it for `emptyValue`. The autocomplete setter did not: it wrapped a restored plain label as `{ key: <label>, value: <label> }` whenever the cell happened to hold an entry, so undoing a column loaded with plain labels produced a fabricated pair a `strict` column then rejected. Return `newValue` untouched when `source` starts with `'UndoRedo.'`.62 - **Guard an empty write.** `isEmpty(newValue)` must skip any resolution, or a `source` entry carrying an empty label stands in for "no value" and `allowEmpty` stops meaning what it says.63 - **Gate the expensive part on a cheap shape check.** The setter runs once per changed cell, so a paste of thousands of rows multiplies whatever it does. `hasKeyValueChoices()` reads only the entries' shape — no string work — so a column whose `source` holds plain strings never pays for a label scan it could not use. Do not memoize the scan itself: a `source` array can be mutated in place by the host application, and a stale displayed-text map would resolve a label to an option no longer offered.64 - **The gate bounds who pays, not how much.** A column that *does* hold key/value entries still runs `findChoiceByDisplayedValue()` per changed cell, and that is a linear scan which calls `stringify()` and `stripTags()` on every choice it walks — `stripTags()` reads the label character by character. Cost is therefore `changed cells × source size`. At realistic dropdown sizes (10–100 options) a 10k-row paste stays in single-digit milliseconds, but a source in the hundreds-to-thousands turns the same paste into roughly a second of scanning. That is the accepted price of never serving a stale option; if a source that large ever needs to be fast, the fix is a map invalidated by identity, not a plain cache.6566## Reference implementations6768- `src/cellTypes/numericType/numericType.ts` - Composes numeric editor, renderer, and validator.69- `src/cellTypes/textType/textType.ts` - Simplest type, good starting template.70- `src/cellTypes/dateType/dateType.ts` - Date handling with format options.71- `src/cellTypes/checkboxType/checkboxType.ts` - Boolean toggle pattern.7273## Common mistakes7475- Forgetting to register the cell type in `src/cellTypes/registry.ts`.76- Not adding the type to `metaSchema.ts`, causing Handsontable to ignore the type name.77- Duplicating editor/renderer/validator logic instead of importing existing components.78- Not exporting from `src/cellTypes/index.ts` for the full bundle.