Skip to content
Query.Farm
Talk with Us

Value representations

Every value DuckDB exchanges with a worker has an Arrow type, and each Arrow type maps to exactly one JS value shape. Getting that mapping wrong is the most common source of silently wrong output in a TypeScript worker, because nothing throws — a decimal read the wrong way is just a number that is 100× off.

This is the first thing to internalize, and it is not in the package README.

How you read itWhat you get
iterRows(batch), and the typed compute pathsThe rich value — converted by the SDK’s codec
batch.getChildAt(i).get(n)The backend’s native value, unconverted

For int64 the two happen to coincide — both give a bigint — which is exactly why the difference goes unnoticed until a date or a decimal is involved. Reading one DECIMAL(18,2) value of 123.45 both ways:

// Raw: the backend's own storage.
batch.getChildAt(0)!.get(0);           // DecimalBigNum [12345, 0, 0, 0]

// Rich: the codec runs. iterRows yields one object per row, keyed by column
// name — a scalar function's input columns are positional, so they come
// through as col_0, col_1, …
const [row] = Array.from(iterRows(batch));
row.col_0;                             // 12345n  — unscaled bigint

The first is the raw 128-bit limb array the Arrow backend stores. It is not wrong, it is lower level — and it is backend-specific, so the same code reading it would see something else under flechette on Cloudflare Workers.

Use getChildAt for numbers and strings; use iterRows for everything else

getChildAt is the right tool for int*, float*, bool, utf8 and binary, where the native value is already the value you want, and it avoids materializing rows. For dates, timestamps, durations, decimals, and nested types go through iterRows (or a typed compute) so the codec runs. The examples in these docs use getChildAt only on integer columns for that reason.

Under the default rich representation, every type is its canonical wire unit except date32 and date64, which surface as a JS Date:

Arrow typerich JS value
boolboolean
int8int32, uint8uint32number
int64 / uint64bigint
float16 / float32 / float64number
utf8 / largeUtf8string
binary / fixedSizeBinaryUint8Array
date32 / date64Date
time32 / time64number / bigint in the declared unit
timestamp[s/ms/us/ns]bigint in the declared unit
duration[s/ms/us/ns]bigint in the declared unit
decimal128 / decimal256bigint, unscaled
struct{ field: richValue }
list / largeList / fixedSizeListArray<richValue | null>
mapArray<[richKey, richValue]>

null and undefined pass through as null for every type.

A Date holds milliseconds. A timestamp[us] holds microseconds, and a timestamp[ns] nanoseconds — so narrowing either to a Date throws away real precision on every value. The SDK will not do that silently, so sub-second temporal types stay numeric:

-- 2024-10-19 12:34:56.789 as timestamp[us]
1729341296789000n   -- exact microseconds, round-trips unchanged

date32 is the exception because a day number has no sub-millisecond content to lose.

A DECIMAL(18,2) value of 123.45 reaches you as 12345n. The scale travels with the column type, not with the value, so applying it is your job:

const scale = 2;                     // from the declared type
const value = 12345n;
Number(value) / 10 ** scale;         // 123.45

This is the one that produces plausible, wrong output rather than an error — a report that is off by exactly a power of ten is the symptom.

Opting a function into repr: "raw" swaps the ergonomic shape for the canonical wire unit carrying a branded TypeScript type — TimestampMicros, Date32, UnscaledDecimal and so on. At runtime a branded value is the underlying number/bigint; the brand exists only at compile time, so mixing units becomes a type error instead of a silent bug:

import { defineScalarFunction, timestampMicros, asTimestampMicros, iterRows, type TimestampMicros } from "@query-farm/vgi";

const addHour = defineScalarFunction({
name: "add_hour",
params: { ts: timestampMicros() },
returns: timestampMicros(),
repr: "raw",
compute: (batch) => {
  // "raw" on BOTH ends: the read as well as the declaration.
  const rows = Array.from(iterRows(batch, "raw") as Iterable<{ col_0: TimestampMicros | null }>);
  return rows.map((r) =>
    r.col_0 == null ? null : asTimestampMicros(r.col_0 + 3_600_000_000n));
},
});
SELECT r.add_hour(TIMESTAMP '2024-10-19 12:34:56.789');

Output

add_hour(…)
2024-10-19 13:34:56.789

Under raw, date32 is a plain day-number rather than a Date — that is the only representational difference between the two modes.

repr: 'raw' does not change what getChildAt returns

This is the trap the previous section sets up, and it is worth seeing concretely. Write the same function reading the column directly:

const ts = batch.getChildAt(0)! as Iterable<TimestampMicros | null>;
return Array.from(ts, (v) => (v == null ? null : asTimestampMicros(v + 3_600_000_000n)));

It typechecks — the cast asserts a branded bigint and the compiler believes it — and then fails at runtime with “Invalid mix of BigInt and other type in addition”, because arrow-js hands back a millisecond number for a timestamp[us] column. The repr setting governs the codec, and getChildAt is the path that skips the codec. Read through iterRows(batch, "raw") and the branded types are real rather than asserted.

Reach for raw when units are the risk

rich is right for most functions. raw earns its extra ceremony when a function juggles several temporal units, or hands values to something else that has its own opinion about them — the compiler then refuses to let a TimestampMillis stand in for a TimestampMicros.

Building output goes through the same codec, and it throws rather than truncating: a non-integer where an integer is required, a bigint that overflows the declared width, an out-of-range Date, a fixedSizeBinary of the wrong length. You get a codec[<type>]: … TypeError at build time instead of corrupt data on the wire.

Reads and writes are symmetric, so build(read(x)) round-trips in either representation.

Never test `constructor.name`

The type factories return Arrow type instances, not classes named after the factory. dateDay() returns a Date_ whose typeId is Type.Date and whose unit is DateUnit.DAY — there is no class called DateDay, so type.constructor.name === "DateDay" is always false. It also breaks across the two Arrow backends and under minification.

Use the exported predicates, or compare typeId and unit:

import { dateDay, isDate, DateUnit, TypeId } from "@query-farm/vgi";

const t = dateDay();
isDate(t);                  // true — backend-agnostic
t.typeId === TypeId.Date;   // true
t.unit === DateUnit.DAY;    // true — day-resolution date32