typed-xlsx
Core Concepts

Row Model

How one input row becomes several worksheet rows, and how refs.column(), row.series(), expansion and summaries behave on expanded reports.

In report mode, one object from your rows array can produce several worksheet rows. Every formula API in typed-xlsx is built around that split:

  • a logical row is one object from rows
  • a physical row is one row of the worksheet

refs.column() and column.cells() work on physical rows. row.series() and column.rows() work on logical rows. The rest of this page shows what each one writes into the file.

One input row, several worksheet rows

When an accessor returns an array, the logical row expands into one physical row per item. Columns with a single value are merged across the expanded rows.

import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
customer
: string;
monthlyAmounts
: number[] }>()
.
column
("customer", {
header
: "Customer",
accessor
: "customer" })
.
column
("monthlyAmount", {
header
: "Monthly amount",
accessor
: (
row
) =>
row
.
monthlyAmounts
,
}) .
build
();
One logical row
{
  customer: "Acme",
  monthlyAmounts: [100, 120, 140],
}
A
B
1
2
3
4
Customer
Monthly amount
Acme
100
120
140
1
2
  1. 1A2:A4customer has one value, so it is merged across the three rows
  2. 2B2:B4monthlyAmounts has three values, one per physical row

Sub-row expansion is report-mode only. In excel-table mode every logical row stays one physical row.

refs.column() points at the current physical row

refs.column(id) references the cell of that column on the same worksheet row as the formula. On an expanded row, each physical row gets its own formula.

import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
qty
: number[];
unitPrice
: number[] }>()
.
column
("qty", {
header
: "Qty",
accessor
: (
row
) =>
row
.
qty
})
.
column
("unitPrice", {
header
: "Unit price",
accessor
: (
row
) =>
row
.
unitPrice
})
.
column
("lineTotal", {
header
: "Line total",
formula
: ({
refs
}) =>
refs
.
column
("qty").
mul
(
refs
.
column
("unitPrice")),
expansion
: "expand",
}) .
build
();
A
B
C
1
2
3
4
Qty
Unit price
Line total
2
50
=(A2*B2)
3
40
=(A3*B3)
1
80
=(A4*B4)
1
  1. 1C2:C4one formula per physical row, each pointing at its own row

Use it for line totals, per-line margins, and any comparison that should follow each worksheet row.

row.series() points at the whole logical row

row.series(id) references every physical cell the current logical row produced for that column. It is the tool for one result per source record.

import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
customer
: string;
monthlyAmounts
: number[] }>()
.
column
("customer", {
header
: "Customer",
accessor
: "customer" })
.
column
("monthlyAmount", {
header
: "Monthly amount",
accessor
: (
row
) =>
row
.
monthlyAmounts
,
}) .
column
("rowAverage", {
header
: "Average",
formula
: ({
row
,
fx
}) =>
fx
.
round
(
row
.
series
("monthlyAmount").
average
(), 2),
expansion
: "single",
}) .
build
();
A
B
C
1
2
3
4
Customer
Monthly amount
Average
Acme
100
=ROUND(AVERAGE(B2:B4),2)
120
140
1
2
  1. 1B2:B4row.series("monthlyAmount") covers the logical row's span
  2. 2C2:C4one result for the logical row, merged (expansion: "single")

row.series() exposes .sum(), .average(), .min() and .max().

Both compose with the same operators

refs.column() and row.series() return the same kind of formula operand, so .mul(), .sub(), fx.round() and the rest work on both. What changes is the cells they point at.

import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
qty
: number[];
unitPrice
: number[];
discountPct
: number;
}>() .
column
("qty", {
header
: "Qty",
accessor
: (
row
) =>
row
.
qty
})
.
column
("unitPrice", {
header
: "Unit price",
accessor
: (
row
) =>
row
.
unitPrice
})
.
column
("discountPct", {
header
: "Discount",
accessor
: "discountPct" })
.
column
("lineNet", {
header
: "Line net",
formula
: ({
refs
,
fx
}) =>
refs .
column
("qty")
.
mul
(
refs
.
column
("unitPrice"))
.
mul
(
fx
.
literal
(1).
sub
(
refs
.
column
("discountPct"))),
expansion
: "expand",
}) .
column
("orderAverage", {
header
: "Order average",
formula
: ({
row
,
fx
}) =>
fx
.
round
(
row
.
series
("lineNet").
average
(), 2),
expansion
: "single",
}) .
build
();
A
B
C
D
E
1
2
3
4
Qty
Unit price
Discount
Line net
Order average
2
50
0.1
=((A2*B2)*(1-C2))
=ROUND(AVERAGE(D2:D4),2)
3
40
=((A3*B3)*(1-C2))
1
80
=((A4*B4)*(1-C2))
1
2
3
  1. 1C2:C4a merged value is referenced through its first row, C2
  2. 2D2:D4computed per physical row
  3. 3E2:E4aggregates those results once per logical row

expansion decides how many cells get the result

The formula decides what to compute. expansion decides where it is written on an expanded row:

ValueWrites
"single"once, on the first physical row, merged across the rest
"expand"on every physical row
"auto"the default: single when the formula reads a whole logical row (row.series), expand when it reads refs.column
import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
amounts
: number[] }>()
.
column
("amount", {
header
: "Amount",
accessor
: (
row
) =>
row
.
amounts
})
.
column
("averageOnce", {
header
: "Average once",
formula
: ({
row
}) =>
row
.
series
("amount").
average
(),
expansion
: "single",
}) .
column
("averageRepeated", {
header
: "Average repeated",
formula
: ({
row
}) =>
row
.
series
("amount").
average
(),
expansion
: "expand",
}) .
build
();
A
B
C
1
2
3
4
Amount
Average once
Average repeated
100
=AVERAGE(A2:A4)
=AVERAGE(A2:A4)
120
=AVERAGE(A2:A4)
140
=AVERAGE(A2:A4)
1
2
  1. 1B2:B4single
  2. 2C2:C4expand

Summaries make the same choice

Summary formulas below the table pick between physical cells and logical rows too:

  • column.cells() aggregates every worksheet cell in the column
  • column.rows() resolves one value per logical row first, then aggregates those

With two customers where Acme expanded into three rows, the two averages differ:

import { 
createExcelSchema
} from "typed-xlsx";
const
schema
=
createExcelSchema
<{
customer
: string;
monthlyAmounts
: number[] }>()
.
column
("customer", {
header
: "Customer",
accessor
: "customer",
summary
: (
summary
) => [
summary
.
label
("Cells avg"),
summary
.
label
("Rows avg")],
}) .
column
("monthlyAmount", {
header
: "Monthly amount",
accessor
: (
row
) =>
row
.
monthlyAmounts
,
summary
: (
summary
) => [
summary
.
formula
(({
column
}) =>
column
.
cells
().
average
()),
summary
.
formula
(({
column
}) =>
column
.
rows
().
average
((
row
) =>
row
.
cells
().
average
()),
), ], }) .
build
();
A
B
1
2
3
4
5
6
7
Customer
Monthly amount
Acme
100
120
140
Globex
300
Cells avg
=AVERAGE(B2:B5)
Rows avg
=AVERAGE(AVERAGE(B2:B4),AVERAGE(B5:B5))
1
2
  1. 1B6column.cells(): (100 + 120 + 140 + 300) / 4 = 165
  2. 2B7column.rows(): (120 + 300) / 2 = 210, each customer counts once

See Summary Rows for labels, reducers and styling.

Which one to use

You wantUse
a value per worksheet row (line totals, per-line checks)refs.column()
one value per source record (order totals, customer averages)row.series()
that value written once and mergedexpansion: "single"
that value repeated on every expanded rowexpansion: "expand"
a summary over every worksheet cellcolumn.cells()
a summary where each source record counts oncecolumn.rows()

Next: Formula Columns covers the full DSL, and Scope & References covers which columns a formula may reference.

Copyright © 2026 Cyprien Thao. Released under the MIT License.