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.
The two access paths do not agree
Section titled “The two access paths do not agree”This is the first thing to internalize, and it is not in the package README.
| How you read it | What you get |
|---|---|
iterRows(batch), and the typed compute paths | The 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.
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.
rich, the default
Section titled “rich, the default”Under the default rich representation, every type is its canonical wire unit except date32
and date64, which surface as a JS Date:
| Arrow type | rich JS value |
|---|---|
bool | boolean |
int8 … int32, uint8 … uint32 | number |
int64 / uint64 | bigint |
float16 / float32 / float64 | number |
utf8 / largeUtf8 | string |
binary / fixedSizeBinary | Uint8Array |
date32 / date64 | Date |
time32 / time64 | number / 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 / decimal256 | bigint, unscaled |
struct | { field: richValue } |
list / largeList / fixedSizeList | Array<richValue | null> |
map | Array<[richKey, richValue]> |
null and undefined pass through as null for every type.
Why timestamps are not Date
Section titled “Why timestamps are not Date”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.
Why decimals are unscaled
Section titled “Why decimals are unscaled”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.
raw, and the branded types
Section titled “raw, and the branded types”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.
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.
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.
The codec validates
Section titled “The codec validates”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.
Discriminating types
Section titled “Discriminating types”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
Next steps
Section titled “Next steps”- Converting by hand →
codecFor(type)on Value representations returns the codec directly. - The type factories → Arrow types.
- The wire format underneath → Argument serialization.