Most data grids know at least four kinds of value: text, number, date, boolean. That is fine for an order list. It falls apart the moment the data is a link budget, a pressure log, a register dump or a fleet of IP addresses, because every one of those has rules that a plain number does not know.
A gigabyte column that shows 0.5 is wrong. A temperature column with a footer total is wrong. An average bearing of 359° and 1° that reads 180° is wrong in a way that looks right. You can format the output easily, but then you have to start building custom comparators for sorting, and have to enforce how data is entered.
I built Lattice Grid for applications (like our others) that show real amounts of technical data, so the type system had to carry that knowledge instead of leaving it to every screen.
A type is a bundle of behaviour, not a label
In Lattice Grid, setting type on a column configures everything at once: how a value is formatted, how typed text is parsed back into a value, how two values compare when sorting, how the column is stored in the columnar backing, what lands in Excel, and what goes on the clipboard.
columns: [
{ field: 'span', type: 'metres' }, // 1500 → "1.5 km"
{ field: 'inlet', type: 'pressure' }, // 200000 → "200 kPa"
{ field: 'cap', type: 'capacitance' }, // 4.7e-11 → "47 pF"
{ field: 'bearing', type: 'degrees' },
{ field: 'inletTemp', type: 'celsius' },
{ field: 'gateway', type: 'ipv4' },
{ field: 'flags', type: 'hex32' },
]
We have 7 core types (text, number, boolean, date, dateString, object, lookup) and 88 technical ones ship, grouped by domain:
Temporal: datetime, timestamp, time, duration
Network: ipv4, ipv6, cidr, mac
Numeric bases: hex, hex8, hex16, hex32, binary, binary8, octal
Computing units: bytes, megabytes, gigabytes, bitrate, gigabits
Physical and engineering units: length, mass, duration, speed, acceleration, area, volume, energy, power, force, pressure, torque, density, flow and angle
Electrical and scientific: voltage, current, resistance, capacitance, inductance, charge, conductance, flux density, illuminance, absorbed and equivalent dose, radioactivity, molarity, viscosity, thermal conductivity, frequency and more
Temperature: celsius, fahrenheit, kelvin
Currency: currency, usd, eur, gbp, jpy
Structured: json, colour, rating, percent
Units: store one number, make only the display unit-aware
Units: store one number, make only the display unit-aware
The design rule for every unit type is the same. The column stores a plain number in a named base unit, and only display and input know about units. A gigabyte column holding 0.5 shows 512 MB, accepts 512M typed over it, and stores 0.5 throughout. Because the backing is still a typed array, sorting, filtering, grouping and totals are ordinary arithmetic and never touch rendered text.
display: 'auto' walks the coherent SI ladder and picks the largest unit that fits. significantFigures rounds to a fixed precision rather than a fixed number of decimals, and rounding happens before the unit is chosen, so 999,999 bytes to three figures is 1.00 MB, not 1,000 kB. compound: ['ft', 'in'] renders one stored number as 5 ft 11 in and parses it back the same way.
Seventeen unit systems ship, and you can add your own:
import { registerUnitSystem, defineUnit, createUnitType } from '@toclocoinc/lattice-grid';
// factor is how many BASE units one of these is. Exactly one must be 1.
registerUnitSystem('yarn', [
defineUnit('tex', 1, ['tx']),
defineUnit('ktex', 1e3, [], { prefix: 'k' }),
defineUnit('den', 1 / 9, ['denier']),
]);
createGrid(el, {
dataTypes: { linearDensity: createUnitType({ system: 'yarn', unit: 'tex', display: 'auto' }) },
columns: [{ field: 'count', type: 'linearDensity' }],
});
The rules that protect the data
Three decisions in the unit layer are there because the alternative is silent corruption.
Ambiguous units are refused, not guessed. A US gallon and an imperial gallon differ by about a fifth, and "ton" means three different masses. Each has its own symbol (gal (US), ton (UK)), and the bare word is rejected on input rather than resolved to a default. The same rule catches case: mV and MV are a billion apart, so both exact spellings work and mv is refused.
Customary units are accepted but never chosen. With the calorie, the BTU and the kilojoule all on one ladder, auto would render 4,000 J as 3.79 BTU because the BTU happens to be the larger unit. So auto walks SI only; ask for a BTU by name and you get one.
Some quantities are not units at all. Temperature carries an offset, so no factor converts it: celsius, fahrenheit and kelvin convert on input (type 72 F into a Celsius column and it stores 22.2) and refuse to be summed, because twenty degrees plus twenty degrees is not forty. Angles wrap, so a degrees column averages by direction and reports nothing when the angles cancel, while the sum stays arithmetic because 720° of total rotation is a real figure. Currency is an amount and a code, never a fixed factor; you pass a rate source to createCurrencyType, and a rate that is needed but absent renders as a loud missingRate marker, never as zero.
Time: a wall clock and an instant are different types
A date column stores '2024-03-11' as a string, not a Date. new Date('2024-03-11') is midnight UTC, which renders as the 10th in New York, so a delivery date set in London would show a day early in Mumbai. Storing the wall-clock string means the date a user typed is the date everyone sees. It is also faster: ISO 8601 sorts lexicographically in chronological order, and repeated dates dictionary-encode well.
When you mean a genuine moment, an audit time or a cross-region log line, use timestamp. It ingests epoch-millis, a Date or a zone-bearing ISO string, stores epoch-millis UTC, and sorts, filters and compares on the instant, so rows from different origin zones order by true chronology and changing the display zone never reorders them. The display zone resolves from the column, then the grid, then the viewer, and is nameable in the cell (Europe/London (BST)), with showOrigin: true when the origin differs. Grouping buckets by civil day in the display zone, so a 23- or 25-hour daylight-saving day still collapses to one bucket. Excel export writes the display-zone wall clock and names the zone, never a silent shift to UTC.
Types the grid reads from your data
A column that declares no type takes one from the rows. The first hundred non-empty values are sampled, and a type is adopted only if every one matches; anything mixed stays text and says so once on the console.
columns: [
{ field: 'sku' }, // text
{ field: 'quantity' }, // number: aligned right, numeric filter
{ field: 'shipped' }, // date
{ field: 'expedited' }, // boolean: checkbox editor
]
Only the core names are ever inferred, so a column of IP addresses or durations is text until you name the type. Inference never overrules a decision you made, and type: false turns sampling off. Sorting is not what you gain by declaring: the default comparator is already value-aware, so an undeclared IPv4 column already orders correctly across 128.0.0.1. What a declared type settles is everything around the sort: the editor, the parser, the formatter, the filter kind, the storage, and the export.
Building a type to order
Two factories cover most custom cases. createUnitType you have seen. createRadixType builds a register view:
dataTypes: {
reg32: LatticeGrid.createRadixType({
radix: 2, bitWidth: 32, pad: true, signed: false, group: 8,
}),
},
columns: [{ field: 'flags', type: 'reg32' }],
// 170 → 0b00000000 00000000 00000000 10101010
Base 10 is deliberately not among the radices: a decimal number is what number is for, with grouping, decimals and currency that a radix formatter has no concept of. Pass radix: 10 and the console names the supported set and falls back to hex, rather than producing something that looks like a number column and is not one.
When a factory is not enough, a type is a plain object with a small set of properties for you to set:
| Member | What it decides |
|---|---|
base |
The storage family: text, number, boolean, date, dateString or object |
extends |
The type to inherit from; defaults merge rather than replace, so a derived type overrides one default and keeps the rest |
matches |
Whether a sampled value belongs to this type, for inference |
format / parse
|
Display text out, typed or pasted text back in |
compare |
Sort order |
defaults |
The filter kind, editor, footer total, alignment and cell renderer a column gets for free |
storage |
float64, int32, bitset, dictionary or object
|
totals |
Which aggregates mean something for this type, and the type's own reduction for each |
That last member is the one we are really finding useful. A type can declare which aggregates are meaningful.
Formats, when you need them
On top of a type sits an optional format, with a shorthand for the common cases and a full spec when the shorthand runs out:
A key in format that the resolved type does not read, a typo or a key from a different shape, is never silently dropped. It warns once, by column and key name.
Why this matters
I spent a lot of time talking to the people who were using our SAAS for 8 hours a day, looking at grids on the screen. It was their frustrations and comments that led to Lattice Grid. Making their lives easier so they don't have to type in 10GB when something they are pasting into a cell says 10000MB. Making sure they don't enter 10,000GB by mistake.
The grid carries the rules, so your users do not have to carry them in their heads, and a wrong number fails loudly at construction instead of quietly in a report later.
Top comments (0)