Your First Real TypeScript Program
Building a small tool end to end: modules, types at the edges, async work, error handling and a first test — every earlier lesson assembled into one working thing.
Building a small tool end to end: modules, types at the edges, async work, error handling and a first test — every earlier lesson assembled into one working thing.
Twenty lessons of pieces. This one builds a thing.
We are going to write a command-line tool that reads a folder of photos, produces captions, writes a report, and behaves sensibly when the folder does not exist or a file is unreadable. It will be split across modules, it will have tests, and you will be able to hand it to someone else.
Nothing new is introduced. The point is the assembly — seeing which parts of a real program are the interesting bit and which are the scaffolding every program has.
$ npm start -- ./photos --limit 50
Scanned 63 files in ./photos
captioned 58
skipped 5 (3 unsupported, 2 unreadable)
Report written to photos/captions.jsonFour requirements, each forcing a decision: walk a folder, caption each image, skip what it cannot handle and say how many, write a report.
src/
├── captions/
│ ├── index.ts makeCaption, isSupported
│ └── errors.ts the error types
├── scanning/
│ └── index.ts finding files
├── report/
│ └── index.ts building and writing
└── main.ts arguments, orchestration, output
tests/
└── captions.test.tsSplit by subject, as the modules lesson had it. Each folder has one job, and the dependencies point one way.
scanning/
Finds files on disk and hands over paths. Knows nothing about captions.
captions/
Turns a path into a caption. Knows nothing about folders, so it can be tested with no files at all.
report/
Combines them into a value. Still returns rather than prints.
main.ts
Knows about all three, and is the only file that reads arguments or prints anything.
That last point is a rule worth stating: the code that decides things should not be the code that prints things. A function that both computes and prints cannot be tested without capturing output, and cannot be reused by anything that wants the value.
// src/captions/errors.ts
export class CaptionError extends Error {
constructor(message: string, readonly filename: string) {
super(message);
this.name = "CaptionError";
}
}
export class UnsupportedFormatError extends CaptionError {}// src/captions/index.ts
import { UnsupportedFormatError } from "./errors.js";
const SUPPORTED = new Set([".jpg", ".jpeg", ".png", ".gif"]);
export function isSupported(filename: string): boolean {
return SUPPORTED.has(extensionOf(filename));
}
export function makeCaption(filename: string): string {
if (!isSupported(filename)) {
throw new UnsupportedFormatError(
`cannot caption ${extensionOf(filename)}`,
filename,
);
}
return stemOf(filename)
.replaceAll(/[_-]+/g, " ")
.split(" ")
.filter((word) => word.length > 0)
.map((word) => word[0]!.toUpperCase() + word.slice(1))
.join(" ");
}
function extensionOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot === -1 ? "" : filename.slice(dot).toLowerCase();
}
function stemOf(filename: string): string {
const dot = filename.lastIndexOf(".");
return dot === -1 ? filename : filename.slice(0, dot);
}Several things from earlier lessons, each doing a job.
A Set for the supported extensions, because membership is the
only question ever asked of it. A base error class with a
specific one beneath it, so callers can catch either level.
extensionOf and stemOf are not exported — private to the
module, and the checker enforces it.
The word[0]! deserves a note. With
noUncheckedIndexedAccess on, indexing gives
string | undefined, and here the filter above guarantees the
word is non-empty. That is the narrow legitimate use of ! the
absence lesson described: something the checker cannot know that
the line above establishes.
// src/scanning/index.ts
import { readdir } from "node:fs/promises";
import { join } from "node:path";
export async function findFiles(
folder: string,
limit?: number,
): Promise<string[]> {
const entries = await readdir(folder, { withFileTypes: true });
const files = entries
.filter((entry) => entry.isFile())
.map((entry) => join(folder, entry.name))
.sort();
return limit == null ? files : files.slice(0, limit);
}Two decisions worth naming.
It lets the failure through. A missing folder throws from
readdir, and this function does not catch it — because it has
no better answer than the one the operating system gave, and
main is where a user-facing message belongs.
It sorts. Directory order varies between machines, and a sort costs nothing here while making the output reproducible — which is what lets a test assert on it.
// src/report/index.ts
import { writeFile } from "node:fs/promises";
import { CaptionError, makeCaption } from "../captions/index.js";
export type Report = {
captions: Record<string, string>;
unsupported: number;
unreadable: number;
};
export function buildReport(paths: readonly string[]): Report {
const captions: Record<string, string> = {};
let unsupported = 0;
let unreadable = 0;
for (const path of paths) {
try {
captions[path] = makeCaption(path);
} catch (error) {
if (error instanceof CaptionError) {
unsupported += 1;
} else {
console.warn(`could not read ${path}:`, error);
unreadable += 1;
}
}
}
return { captions, unsupported, unreadable };
}
export async function writeReport(
path: string,
report: Report,
): Promise<void> {
await writeFile(path, JSON.stringify(report.captions, null, 2), "utf8");
}buildReport returns a value rather than printing one, which is
what makes it testable. The catch narrows with instanceof —
a failure it planned for is counted, and anything else is logged
and counted separately rather than swallowed.
readonly string[] on the parameter says this function will not
modify the caller's array, which is the "ask for the least you
need" rule from the functions lesson.
// src/main.ts
import { parseArgs } from "node:util";
import { join } from "node:path";
import { findFiles } from "./scanning/index.js";
import { buildReport, writeReport } from "./report/index.js";
async function main(): Promise<void> {
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
limit: { type: "string" },
"dry-run": { type: "boolean", default: false },
},
});
const folder = positionals[0];
if (folder == null) {
console.error("Usage: photo-tools <folder> [--limit N] [--dry-run]");
process.exit(2);
}
const limit = values.limit == null ? undefined : Number(values.limit);
if (limit !== undefined && !Number.isFinite(limit)) {
console.error(`--limit must be a number, got ${values.limit}`);
process.exit(2);
}
const paths = await findFiles(folder, limit);
const report = buildReport(paths);
const captioned = Object.keys(report.captions).length;
const skipped = report.unsupported + report.unreadable;
console.log(`\nScanned ${paths.length} files in ${folder}`);
console.log(` captioned ${captioned}`);
if (skipped > 0) {
console.log(
` skipped ${skipped} (${report.unsupported} unsupported, ` +
`${report.unreadable} unreadable)`,
);
}
if (values["dry-run"]) {
console.log("\nDry run - nothing written.");
return;
}
const output = join(folder, "captions.json");
await writeReport(output, report);
console.log(`\nReport written to ${output}`);
}
main().catch((error: unknown) => {
console.error("Error:", error instanceof Error ? error.message : error);
process.exit(1);
});Three things at the bottom carry weight.
process.exit(1) on failure. A non-zero exit code is what a
shell script or a CI job checks, and a program that prints
"Error:" and exits zero reports success to everything except a
human reading the screen.
main().catch(...) is the missing-await rule from the
async lesson. main returns a promise; without that .catch,
any failure inside it is an unhandled rejection rather than a
message.
--dry-run costs four lines and lets someone run this
against real data to see what would happen. That is the
difference between a tool people try and one they avoid.
// tests/captions.test.ts
import { describe, expect, it } from "vitest";
import { makeCaption } from "../src/captions/index.js";
import { UnsupportedFormatError } from "../src/captions/errors.js";
describe("makeCaption", () => {
it("turns separators into words", () => {
expect(makeCaption("sunrise_over_lisbon.jpg")).toBe(
"Sunrise Over Lisbon",
);
});
it("handles hyphens and repeats", () => {
expect(makeCaption("tram--28.png")).toBe("Tram 28");
});
it("rejects an unsupported format", () => {
expect(() => makeCaption("notes.txt")).toThrow(UnsupportedFormatError);
});
});Notice what is tested: the function that makes a decision, not
the one that touches the disk. makeCaption takes a value and
returns a value, so testing it needs no folder, no files and no
setup — a direct payoff from keeping it separate from everything
that reads the world.
Most of the program is reading input safely, handling what goes wrong, and reporting clearly.
That ratio is not a sign you did something wrong. It is what working software looks like, and it is the main thing separating a program from a snippet. The snippet assumes the folder exists, the files are readable and nobody mistyped an argument. The program does not.
THE SHAPE OF A REAL PROGRAM
a module per subject; index.ts as its front door
one entry point, short
deciding and printing in DIFFERENT functions
let failures through where you have no better answer
catch what you planned for; count or log the rest
exit non-zero on failure
main().catch(...) so a rejection is not unhandled
a --dry-run for anything that writes
WHAT CAME FROM WHERE
Set for membership collections
a base error + specifics errors
instanceof narrowing unions
readonly parameters functions
word[0]! after a filter absence - the narrow legit use
async/await + .catch at the top promises
.js extensions in imports modules
noUncheckedIndexedAccess configuring the compiler
THE PROPORTION
the subject matter is not the biggest file
most of a program is input, failure and reporting
that is the difference between a snippet and something you
can hand to someoneYou have written a real program. It reads the world, survives what it finds, is split into parts that can be understood separately, and has tests for the part that makes decisions. That is a genuine milestone — most of what remains is depth rather than new kinds of thing.
Next is the course TypeScript in Practice, which asks the questions a working codebase asks. How do you make the type system prevent bugs rather than describe code? What happens when data arrives from outside and your types were only ever a claim? How do generics let one function work for many types without losing the specific one? And what do you do about the module errors that only appear at runtime?
Before you go, extend this tool. Add a --format csv option.
Add a summary of the commonest words across all captions using a
Map. Make it skip files above a size you pass in. Each is a
small change to a program you understand, which is the most
efficient practice there is — and the first time you extend your
own code without breaking it is when this stops feeling like
exercises and starts feeling like building things.