Chapter 20
Splitting a Program Across Files
import, export, and organizing a real project.
Every program in this book has been one file. That has been fine, and it stops being fine somewhere around two hundred lines, when finding anything means scrolling and every change risks something unrelated.
Two files
Put something in one file and mark it export:
// money.ts
export function formatMoney(n: number): string {
return `$${n.toFixed(2)}`;
}
export type Expense = { title: string; amount: number };
Then import it in another:
// main.ts
import { formatMoney, Expense } from "./money";
const e: Expense = { title: "Coffee", amount: 3.5 };
console.log(formatMoney(e.amount));
Three things about that import line:
- The braces list what you want. You take the pieces you need, not the whole file.
./moneyis a path, relative to the file doing the importing. The./matters, it means “next to me” rather than “a package somewhere”.- No
.tson the end.
Types come across exactly like functions do. Expense is a compile-time
thing that vanishes in the output, and formatMoney is real code, and the
same import handles both.
Anything without export stays private to its file. That’s the second real
benefit after findability: a file can have helpers nobody else can reach,
which is chapter 15’s argument for private applied one level up.
Compile both files by naming both:
npx tsc money.ts main.ts
And now the part that catches everyone.
The compile flag nobody warns you about
Run it:
node main.js
SyntaxError: Cannot use import statement outside a module
The compile was clean. tsc said nothing. And the program won’t start.
Here’s what happened. TypeScript translated your import into JavaScript’s
own import, which is the modern module system. Node supports it, but Node
also supports an older one, and it decides which you meant by looking at
package.json. Yours says "type": "commonjs", because that’s what
npm init -y writes, so Node read a modern import in a file it had been
told was old-style, and stopped.
Two ways out, and this book takes the first:
npx tsc --module commonjs money.ts main.ts
$3.50
--module commonjs tells TypeScript to emit the older style, which matches
what package.json already claims. Your .ts files don’t change at all,
you keep writing import and export, and the translation absorbs the
difference.
Add that flag to your compile command from here on.
Splitting a real program
Two files is a demonstration. Here’s the shape that actually helps, from this chapter’s project:
storage.ts reading the file
words.ts splitting text into words and cleaning them
counter.ts counting them, and ranking the results
main.ts the program
The rule worth following: one file, one job, and a name that says what it is. If you can’t name a file in one word, it’s probably doing two things.
main.ts ends up short, mostly imports and a handful of calls, which is a
good sign rather than a suspicious one.
Importing from several places
import { readText } from "./storage";
import { splitWords, cleanWord } from "./words";
import { countWords, topTen } from "./counter";
One line per file, and now you can read the top of main.ts and know what
the program is made of before you read a line of it.
Files can import each other, and there’s one arrangement to avoid: a.ts
importing from b.ts while b.ts imports from a.ts. It sometimes works
and sometimes produces undefined at runtime for reasons that are genuinely
hard to see, which is a bad combination.
When you find yourself wanting it, the answer is nearly always a third file
holding what they both need. Types are the usual culprit, and a
types.ts that everything imports and nothing else is a common, boring,
effective solution.
Exercise 1 · Cause the error on purpose
Take any two-file program and compile it without --module commonjs.
Read the error from node, then look at the generated .js and find the
import line that Node objected to.
Then compile it again with the flag and diff the two outputs. The
.ts files are identical; only the translation changed. Seeing that once
makes the whole thing much less mysterious than the error message
suggests.
Check yourself
Project
Word frequency, in pieces
Roughly 45 minutes
Take chapter 18’s word counter, which is currently one file, and split it into four. No new behaviour: when you’re done it should produce byte-for-byte the same output it does now, which is the only way to know a refactor went right.
The files:
storage.ts reading the text file, with the existsSync guard
words.ts splitting into words and cleaning them
counter.ts the Map counting, and the top-ten ranking
main.ts wiring the three together and printingDo it in this order, because it’s the order that keeps the program running the whole way:
- Make the new files, empty.
- Move one function, add
export, add theimport, compile and run. Confirm the output is unchanged. - Repeat until
main.tsis short.
Moving everything at once and then fixing the errors is the obvious approach and reliably takes longer, because you lose the ability to tell which move broke it.
Compile with the flag:
npx tsc --module commonjs *.ts && node main.js*.ts means every .ts file in the folder, which saves listing them.
Where the /// <reference types="node" /> line goes. Only
storage.ts touches fs, so only storage.ts needs it. That’s a small
demonstration of the whole point: the file that does the file handling is
the only one that knows about file handling, and the other three are
ordinary code you could test without a disk.
Prove the split worked. Two checks:
- Run it on the same input as chapter 18 and compare the output exactly.
- Try to use one of
words.ts’s helpers frommain.tswithout exporting it, and read the error. That’s the file boundary doing its job.
Stretch: add a types.ts holding a type WordCount = { word: string; count: number } and have counter.ts return an array of those instead
of a Map, with report.ts formatting them. Notice the type is imported
by two files and owned by neither, which is exactly the arrangement that
keeps them from importing each other.