Topic 14.6
TypeScript for Charts
In one line
Type the rows a chart reads, the scales it builds and the props it takes. The compiler then catches a misspelt field, a missing value and the wrong kind of scale before the page ever runs, at the price of a few annotations.
Think of it like this
The labels on a pharmacy shelf. Each box says what is inside and how much. A new assistant cannot put a bottle of liquid where tablets belong, because the shelf label says no. Types are labels on your data: they stop you putting the wrong thing in the wrong place.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- type
- A description of what a value can be, such as
number,Date, or{ date: Date; rides: number | null }. - union
- A type that allows more than one thing:
number | nullis a number or null. - generic
- A type with a blank filled in by the caller:
Bars<T>is a component for rows of any one typeT. - @types package
- A package of type descriptions for a JavaScript library that has none of its own.
- strict
- A compiler setting that turns on the strictest checks, including null and undefined handling.
- type assertion
- Telling the compiler "trust me" with
as. It checks nothing and can hide a real mistake. - TSX
- TypeScript with JSX: the file type for React components written in TypeScript.
Step by step
01A row type and a scale type
The first example types a daily row and a small linear scale function, then a generic Bars<T> component, and renders a week with one missing day. The compiler finds nothing to complain about, and the missing day is skipped on purpose.
02Let the compiler catch the typos
The second example is the same kind of code with three mistakes: a possibly null number passed on, a misspelt field, and a key that does not exist. The Types panel shows tsc's exact messages. A // ^? comment asks the compiler for a type.
03D3's types in a Vite project
In your project, with @types/d3 installed, the compiler protects D3 calls too. The block below was run with TypeScript 5.9.3 and @types/d3 7.4.3 (strict mode) and shows the real errors.
import * as d3 from "d3";
type DayRow = { date: Date; rides: number | null };
declare const rows: DayRow[];
const x = d3.scaleUtc().domain(d3.extent(rows, (d) => d.date)).range([0, 100]);
const top = d3.max(rows, (d) => d.rides);
const y = d3.scaleLinear().domain([0, top]).range([100, 0]);
const ok = d3.scaleLinear().domain([0, 10]).range([0, 5]);
const wrong: d3.ScaleTime<number, number> = ok;04Turn the checks up where data is weakest
In tsconfig.json set "strict": true and "noUncheckedIndexedAccess": true. The second option makes rows[0] possibly undefined, which is the empty-data case that crashes charts. The Types panel above already uses it.
Try it yourself
- 1
Misspell a field in the first example
Change
value={(d) => d.rides}tovalue={(d) => d.ride}. The Types panel saysProperty 'ride' does not exist on type 'DayRow'. Did you mean 'rides'?and nothing has run yet. - 2
Return a string
Change
value={(d) => d.rides}tovalue={(d) => String(d.rides)}. The Types panel saysType 'string' is not assignable to type 'number'.(thenullhalf of the union is not mentioned), a mistake that would otherwise drawNaNwidths. - 3
Allow the missing key
In the second example change
"rides_total"to"date". That error disappears:dateis a real key ofDayRow, andPick1<DayRow, "date">is a function returning aDate, so(r) => r.ridesnow fails with a different error:Type 'number | null' is not assignable to type 'Date'.
Code & diagrams
The compiler is satisfied: the Types panel is empty. `T` was inferred as `DayRow` from `data`, so `d.date` and `d.rides` are checked. The last line shows the all-equal case: a zero-width domain gives the middle of the range (50) and not NaN.
Preview
Console
Nothing logged yet.Expected console output
bars: 2
156.71092184368737 50The program still runs: types are erased, so the console prints a number and then NaN (the typo gave undefined). The Types panel is where the mistakes show up. Rows [0] is `DayRow | undefined` because the panel's compiler settings include `noUncheckedIndexedAccess`.
Preview
Console
Nothing logged yet.Expected console output
25.525000000000006
NaNBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Silence the compiler with `as`
In the first example replace week by const week = [{ date: new Date("2025-06-02"), rides: "2,979" }, { date: new Date("2025-06-03"), rides: 3992 }] as unknown as DayRow[];, as if a CSV cell had kept its thousands comma.
Break #2
Pass the wrong kind of scale
Give a component a prop typed ScaleTime<number, number> and pass it d3.scaleLinear().
Myth vs fact
Myth
TypeScript checks my data at run time.
Fact
Types are erased when the code is built. They only describe what you promise the data looks like. Check real data when it enters (parsing and cleaning), then types keep the rest honest.
Myth
Typing charts means typing every D3 call by hand.
Fact
Most types are inferred. You write the row type, a few component props, and let @types/d3 and inference do the rest.
Myth
any is a harmless shortcut in chart code.
Fact
any switches checking off for everything it touches, including the NaN bugs types would catch. Use unknown and narrow, or write the real type.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Turn on
noUncheckedIndexedAccessfor chart code:rows[0]becomesDayRow | undefined, which forces a check for empty data, the most common chart crash. - ▸
d3.extentreturns[undefined, undefined]for an empty list, typed as[T, T] | [undefined, undefined]. A helperfunction extentOr<T>(values: T[], fallback: [T, T]): [T, T]removes the repetition and makes the empty case explicit. - ▸
For scales, type the inputs you need, not D3's whole interface:
type XScale = { (d: Date): number; invert(n: number): Date }accepts a D3 time scale or a hand-made test double, which makes component tests easy (Topic 16.7). - ▸
Recharts and visx ship their own types, so
<Bar dataKey="ride" />with a misspelt key is only caught if you type the data and the key (dataKey: keyof Row). Recharts typesdataKeyloosely as a string or function, so wrap it in your own typed prop for important charts.
Remember this
- 1
The problem this solves: chart code passes the same few things around: rows, accessor functions and scales. A typo like
d.rideinstead ofd.ridesgivesundefined, thenNaN, then an SVG error, far from the typo. Anullfrom a CSV reaches a scale and draws nothing. TypeScript reports both while you type. The TypeScript course (/typescript) teaches the language; here you use a small part of it. - 2
Type the row first.
type DayRow = { date: Date; rides: number | null }says what one parsed row ofdaily.csvlooks like, including that a day may have no reading. Everything downstream (d3.max,line.defined, a tooltip) is checked against it. Writing the row type is the single most useful step, because the data is where charts go wrong. - 3
A scale is a function with a type. A scale maps a number to a number,
(value: number) => number. D3's own types are richer:ScaleLinear<number, number>(domain number, range number) andScaleTime<number, number>. A linear scale is not assignable to a time scale, becauseinvertreturns a number in one and aDatein the other. The compiler tells you when you pass the wrong kind to a component. - 4
Where the types come from. D3 ships JavaScript only. Its types are in the separate
@types/d3package (and@types/d3-scaleand others). React's are in@types/react. Recharts and visx ship their own types. In your Vite project you install them withnpm install -D typescript @types/d3 @types/react @types/react-dom, and rename.jsxto.tsx. The editor on this page type-checks React examples against@types/reactonly, so the D3-typed code below is shown as a normal block with the real compiler messages. - 5
Generics keep a component reusable.
function Bars<T>({ data, label, value }: BarsProps<T>)works for any list:Tis whatever the caller's rows are, andlabelandvalueare functions from aT. The component never needs to know aboutDayRow. The compiler checks each call:value={(d) => d.rides}must returnnumber | null, andd.rideis an error. - 6
Strict null checks catch the data bugs. With
stricton,d3.max(rows, (d) => d.rides)has typenumber | undefined, because an empty list has no maximum, andd.ridescan be null. Passing it straight to.domain([0, top])is a compile error, which forces you to decide: skip the chart, show a message, or default to 0. This is exactly the empty and null handling the course keeps asking for. - 7
Limits. Types describe code, not data from the network:
d3.csvreturns whatever the file contains, andd3.autoTypeguesses. A type assertion (as DayRow[]) silences the compiler without checking anything, so validate at the boundary (Topic 4.4's cleaning function, or a schema library) and let types guard the code after it. Types are erased when the code is built: they cost nothing at run time.
Explain it without notes
Why is the row type the most useful type in chart code?
What does d3.max(rows, (d) => d.rides) return for an empty list, and how does strict mode make you handle it?
Why can a ScaleLinear not be passed where a ScaleTime is expected?
What does a type assertion (as) do, and why is it dangerous for data from a CSV?
Practice
Write a generic Dots<T> props type for a scatter chart: rows T[], accessors x and y returning numbers, and id returning a string. Then call it with { minutes: number; km: number; id: string } rows.
Write extentOr(values: number[], fallback: [number, number]): [number, number] and say why it helps a scale.
Trade-offs
- ↔
Types catch whole classes of data and prop mistakes early, at the cost of annotations and of keeping
@typespackages in step with the library versions. - ↔
Stricter settings (
strict,noUncheckedIndexedAccess) give more safety and more error messages to resolve on day one: for chart code, where empty and missing data cause most crashes, it is usually worth it.
Done when you can
I can write the row type for a CSV and use it in accessors.
I can write a generic chart component whose props are checked against the caller's rows.
I can install
@types/d3and read the compiler's errors for scales andd3.extent.I can say why
ason parsed data is risky.I can name what types do not check: data at run time.