Handsontable Cell Type Development

SkillDev tools

Use when creating or modifying a Handsontable cell type that composes an editor, renderer, and validator into a reusable configuration object registered by name

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Handsontable Cell Type Development skill

What this skill tells your AI

The instructions your AI receives, as published by handsontable/handsontable in .claude/skills/handsontable-celltype-dev/SKILL.md and read by ahel’s review.

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.

Signals

GitHub stars
22k
Forks
3k
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
handsontable-celltype-dev
Source
github.com/handsontable/handsontable