Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~23 min· topic 6 of 7

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 | null is 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 type T.
@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.

src/charts/typed.tsx (real tsc output below)whole filetsx
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;
terminal
$ npx tsc --noEmit
── expected output ──
src/charts/typed.tsx(6,32): error TS2345: Argument of type '[undefined, undefined] | [Date, Date]' is not assignable to parameter of type 'Iterable<Date | NumberValue>'.
src/charts/typed.tsx(8,35): error TS2345: Argument of type '(number | undefined)[]' is not assignable to parameter of type 'Iterable<NumberValue>'.
src/charts/typed.tsx(10,7): error TS2322: Type 'ScaleLinear<number, number, never>' is not assignable to type 'ScaleTime<number, number, never>'.
The types returned by 'invert(...)' are incompatible between these types.
Type 'number' is not assignable to type 'Date'.
Each error is a real decision: what should the chart do for an empty list (extent is `[undefined, undefined]`), and which kind of scale does this prop want? Long indented lines of explanation are shortened here to the first reason.

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. 1

    Misspell a field in the first example

    Change value={(d) => d.rides} to value={(d) => d.ride}. The Types panel says Property 'ride' does not exist on type 'DayRow'. Did you mean 'rides'? and nothing has run yet.

  2. 2

    Return a string

    Change value={(d) => d.rides} to value={(d) => String(d.rides)}. The Types panel says Type 'string' is not assignable to type 'number'. (the null half of the union is not mentioned), a mistake that would otherwise draw NaN widths.

  3. 3

    Allow the missing key

    In the second example change "rides_total" to "date". That error disappears: date is a real key of DayRow, and Pick1<DayRow, "date"> is a function returning a Date, so (r) => r.rides now fails with a different error: Type 'number | null' is not assignable to type 'Date'.

Code & diagrams

Typed rows, a typed scale and a generic Bars component New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

bars: 2
156.71092184368737 50
Three mistakes the compiler finds before the browser does New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

25.525000000000006
NaN

Break 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.

terminal
$ Console
── what you'll see ──
Received NaN for the `width` attribute. If this is expected, cast the value to a string.
Error: <rect> attribute width: Expected length, "NaN".
Error: <rect> attribute width: Expected length, "NaN".
bars: 2
156.71092184368737 50
No type error. The compiler was told to trust the data, and the text "2,979" became NaN at run time. React warns once, and the browser prints its SVG error for each of the two renders of the component.

Break #2

Pass the wrong kind of scale

Give a component a prop typed ScaleTime<number, number> and pass it d3.scaleLinear().

terminal
$ tsc
── what you'll see ──
error TS2322: Type 'ScaleLinear<number, number, never>' is not assignable to type 'ScaleTime<number, number, never>'.
The types returned by 'invert(...)' are incompatible between these types.
Type 'number' is not assignable to type 'Date'.

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 noUncheckedIndexedAccess for chart code: rows[0] becomes DayRow | undefined, which forces a check for empty data, the most common chart crash.

  • ▸

    d3.extent returns [undefined, undefined] for an empty list, typed as [T, T] | [undefined, undefined]. A helper function 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 types dataKey loosely as a string or function, so wrap it in your own typed prop for important charts.

Remember this

  1. 1

    The problem this solves: chart code passes the same few things around: rows, accessor functions and scales. A typo like d.ride instead of d.rides gives undefined, then NaN, then an SVG error, far from the typo. A null from 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. 2

    Type the row first. type DayRow = { date: Date; rides: number | null } says what one parsed row of daily.csv looks 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. 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) and ScaleTime<number, number>. A linear scale is not assignable to a time scale, because invert returns a number in one and a Date in the other. The compiler tells you when you pass the wrong kind to a component.

  4. 4

    Where the types come from. D3 ships JavaScript only. Its types are in the separate @types/d3 package (and @types/d3-scale and others). React's are in @types/react. Recharts and visx ship their own types. In your Vite project you install them with npm install -D typescript @types/d3 @types/react @types/react-dom, and rename .jsx to .tsx. The editor on this page type-checks React examples against @types/react only, so the D3-typed code below is shown as a normal block with the real compiler messages.

  5. 5

    Generics keep a component reusable. function Bars<T>({ data, label, value }: BarsProps<T>) works for any list: T is whatever the caller's rows are, and label and value are functions from a T. The component never needs to know about DayRow. The compiler checks each call: value={(d) => d.rides} must return number | null, and d.ride is an error.

  6. 6

    Strict null checks catch the data bugs. With strict on, d3.max(rows, (d) => d.rides) has type number | undefined, because an empty list has no maximum, and d.rides can 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. 7

    Limits. Types describe code, not data from the network: d3.csv returns whatever the file contains, and d3.autoType guesses. 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

01

Why is the row type the most useful type in chart code?

02

What does d3.max(rows, (d) => d.rides) return for an empty list, and how does strict mode make you handle it?

03

Why can a ScaleLinear not be passed where a ScaleTime is expected?

04

What does a type assertion (as) do, and why is it dangerous for data from a CSV?

Practice

01

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.

02

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 @types packages 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/d3 and read the compiler's errors for scales and d3.extent.

  • I can say why as on parsed data is risky.

  • I can name what types do not check: data at run time.