Contents

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:

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

1. Your two-file program compiles with no errors and node reports "Cannot use import statement outside a module". What went wrong?

Not quite. The TypeScript is fine. The problem is which flavour of JavaScript came out the other side.

Yes. Compile with --module commonjs so the output matches what package.json claims, or change package.json and accept the .js-extension rule.

Not quite. Worth checking, but that gives a different error about not finding the module.

2. What happens to something in a file that has no export on it?

Not quite. Only exported things are. That is what makes export a decision rather than a formality.

Yes. A file can have helpers nothing else can reach, which is chapter 15s argument for private applied to files.

Not quite. Most of a well-organised file is usually unexported. Exports are the small public part.

3. Why avoid two files importing from each other?

Not quite. It compiles. That is part of the problem.

Yes. The usual fix is a third file holding what both need, and a types.ts is the common version of that.

Not quite. Size is not the issue. Unpredictable runtime behaviour is.

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 printing

Do it in this order, because it’s the order that keeps the program running the whole way:

  1. Make the new files, empty.
  2. Move one function, add export, add the import, compile and run. Confirm the output is unchanged.
  3. Repeat until main.ts is 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 from main.ts without 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.