Row Model
When formulas feel confusing in report mode, the issue is almost always the row model.
The short version:
- a logical row is one source object from your
rowsarray - a physical row is one actual worksheet row after expansion
row.ref(...)works at the physical row levelrow.series(...)works at the logical row level by targeting the full expanded spancolumn.cells()summarizes physical cellscolumn.rows()summarizes logical rows
This page is the canonical explanation of that model.
One input row can become many worksheet rows
In report mode, an accessor that returns an array expands one logical row into multiple physical rows.
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{
customer: string;
monthlyAmounts: number[];
}>()
.column("customer", { accessor: "customer" })
.column("monthlyAmount", {
accessor: (row) => row.monthlyAmounts,
})
.build();
For this input:
{
customer: "Acme",
monthlyAmounts: [100, 120, 140],
}
typed-xlsx renders:
1logical row3physical worksheet rows
That is the core distinction the formula APIs model.
Mental model
Use this rule of thumb:
- reach for
row.ref(...)when the formula should behave like “this worksheet row only” - reach for
row.series(...)when the formula should behave like “the whole original source row”
row.ref(...) targets one physical-row cell
row.ref(columnId) means: reference the cell for that column on the current physical worksheet row.
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{
qty: number[];
unitPrice: number[];
}>()
.column("qty", { accessor: (row) => row.qty })
.column("unitPrice", { accessor: (row) => row.unitPrice })
.column("lineTotal", {
formula: ({ row }) => row.ref("qty").mul(row.ref("unitPrice")),
expansion: "expand",
})
.build();
If one logical row expands to three physical rows, the emitted formulas behave like this:
This is the right tool for:
- line totals
- per-sub-row margins
- comparisons that should track each emitted worksheet row separately
row.series(...) targets the whole logical-row span
row.series(columnId) means: reference the full range of physical cells generated by the current logical row for that column.
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{ monthlyAmounts: number[] }>()
.column("monthlyAmount", {
accessor: (row) => row.monthlyAmounts,
})
.column("rowAverage", {
formula: ({ row, fx }) => fx.round(row.series("monthlyAmount").average(), 2),
expansion: "single",
})
.build();
If monthlyAmount renders in B2:B4, then:
row.series("monthlyAmount").sum()becomesSUM(B2:B4)row.series("monthlyAmount").average()becomesAVERAGE(B2:B4)
This is the right tool for:
- one-per-order totals across expanded line items
- one-per-customer averages across expanded monthly values
- formula cells that summarize a logical record instead of each worksheet row
Operators compose the same way on both
One important point: row.ref(...) and row.series(...) both return formula objects you can keep composing.
That means the difference is not in the operators. The difference is in what each operand points at.
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{
qty: number[];
unitPrice: number[];
discountPct: number;
}>()
.column("qty", { accessor: (row) => row.qty })
.column("unitPrice", { accessor: (row) => row.unitPrice })
.column("discountPct", { accessor: "discountPct" })
.column("lineNet", {
formula: ({ row, fx }) =>
row
.ref("qty")
.mul(row.ref("unitPrice"))
.mul(fx.literal(1).sub(row.ref("discountPct"))),
expansion: "expand",
})
.column("orderAverage", {
formula: ({ row, fx }) => fx.round(row.series("lineNet").average(), 2),
expansion: "single",
})
.build();
Read that as:
lineNet: compute one value per physical roworderAverage: aggregate the whole logical row after those physical-row values exist
Expansion controls where the result is written
The formula itself decides what to compute. expansion decides how many cells receive the result.
"auto": infer based on the formula shape"single": write once for the logical row, merge remaining physical cells"expand": repeat across every physical row of the logical row
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{ amounts: number[] }>()
.column("amount", {
accessor: (row) => row.amounts,
})
.column("averageOnce", {
formula: ({ row }) => row.series("amount").average(),
expansion: "single",
})
.column("averageRepeated", {
formula: ({ row }) => row.series("amount").average(),
expansion: "expand",
})
.build();
In practice:
- use
singlefor “one rollup per logical record” - use
expandfor “repeat the result on every emitted worksheet row”
Summaries use the same row model idea
The same distinction appears in summary formulas.
column.cells()aggregates all physical worksheet cellscolumn.rows()aggregates logical rows first
import { createExcelSchema } from "@chronicstone/typed-xlsx";
const schema = createExcelSchema<{ monthlyAmounts: number[] }>()
.column("monthlyAmount", {
accessor: (row) => row.monthlyAmounts,
summary: (summary) => [
summary.formula(({ column }) => column.cells().average()),
summary.formula(({ column }) => column.rows().average((row) => row.cells().average())),
],
})
.build();
Use:
column.cells()when the physical worksheet range is the thing you want to summarizecolumn.rows()when you want each source row to count once, even if it expanded into many worksheet rows
Report mode vs excel-table mode
This row model matters most in report mode, because report mode supports sub-row expansion.
In excel-table mode:
- sub-row expansion is not supported
- formulas still use the same DSL
row.ref(...)emits structured referencesrow.series(...)is not the practical center of the model because one logical row does not fan out into multiple worksheet rows
Practical checklist
Use this checklist when deciding which API to use:
- use
row.ref(...)for per-physical-row formulas - use
row.series(...)for per-logical-row formulas over expanded cells - use
expansion: "single"when one logical-row result should render once - use
expansion: "expand"when the same result should repeat on every physical row - use
column.cells()when summaries should operate on worksheet cells directly - use
column.rows()when summaries should respect source-row boundaries