Modules, Import, and Export
Splitting a program across files, named and default exports, what a relative import path resolves to, and keeping the dependency arrows pointing one way.
Splitting a program across files, named and default exports, what a relative import path resolves to, and keeping the dependency arrows pointing one way.
Your program is four hundred lines in one file. To change how
captions are generated you scroll. To find where uploads happen
you search. You know there is a function called something like
cleanName but not whether it is above or below the thing that
calls it.
The file is not too long because you wrote too much. It is too long because everything in it is in one place. By the end of this lesson you will split a program across files, know what an import path actually resolves to, and be able to keep the dependencies between your files pointing one way.
Any file with an import or export is a module. What you
export is visible elsewhere; everything else is private to the
file.
// src/captions.ts
export function makeCaption(filename: string): string {
return normalise(filename).replaceAll("_", " ");
}
function normalise(filename: string): string { // not exported
return filename.trim().toLowerCase();
}// src/main.ts
import { makeCaption } from "./captions.js";
console.log(makeCaption("sunrise_over_lisbon.jpg"));normalise is unreachable from outside. That privacy is free
and is most of why splitting files is worth doing — each file
has a small public surface and a private inside.
Types export exactly the same way:
export type Photo = { name: string; size: number };
export interface Uploader { upload(photo: Photo): Promise<void> }You wrote captions.ts and imported "./captions.js". That is
not a typo.
Import paths refer to what will exist after compilation, and
the compiler emits .js. Your editor resolves ./captions.js
to captions.ts and everything works.
Which extension you write depends on your setup:
import { makeCaption } from "./captions.js"; // modern Node, ESM
import { makeCaption } from "./captions"; // bundlers, older setupsIf your project uses "type": "module" in package.json and
modern Node, the .js extension is required and omitting it
fails at runtime with a module-not-found error — after the
checker was perfectly happy. If you use a bundler, either works.
This is the single most common setup confusion in TypeScript projects. Match whatever the rest of your project does, and when an import that looks correct fails only at runtime, this is the first thing to check.
export function makeCaption() { ... } // named
export const MAX_SIZE = 10_000;
export type Photo = { ... };
import { makeCaption, MAX_SIZE, type Photo } from "./captions.js";Named exports are imported by exactly their name, which means your editor can autocomplete them, rename them across the whole project, and tell you when one is misspelled.
There is also a default:
export default function makeCaption() { ... }
import anythingAtAll from "./captions.js"; // any name worksPrefer named exports. A default can be imported under any
name, so the same function ends up called makeCaption in one
file and caption in another, and searching for uses fails.
Renaming does not propagate. Some frameworks require a default
for particular files — follow their convention there and use
named exports everywhere else.
Two more forms worth knowing:
And the one to avoid:
That makes it impossible to see what a file provides without opening every file it re-exports from, and a name added somewhere deep silently appears in your public surface.
Marking an import as type-only tells the compiler it can be erased entirely, since types do not exist at runtime. Two practical benefits: it avoids loading a module purely for a type, and it prevents a circular import that only annotations needed.
Turn on "verbatimModuleSyntax": true and the checker will
require the marker where it applies, which removes the decision.
Bad — a file named after what things are.
Good — a file per subject.
Every change touches every file.
Changing anything about captions means editing types.ts,
utils.ts and constants.ts — three files shared with
everything else, each a merge conflict waiting to happen.
And utils.ts becomes where functions go when nobody could
categorise them. It only grows, and nothing in it can be
deleted because nobody knows what uses it.
A change touches one folder.
The folder name tells you what belongs there, and what does not.
When you find yourself adding a fourth unrelated function to
a utils, that is the signal to split by subject.
An index.ts per folder gives each area a front door:
Now the individual files can be reorganised without changing any importer.
Two files importing each other is the structural problem you will meet first:
The symptom is a value that is undefined at the moment a
module runs, because one file was still being evaluated when the
other asked for something from it. It often works until you
change the order of something unrelated, which makes it
memorably hard to diagnose.
The cycle is telling you something true: these two files are entangled.
Is it only a type?
Then import type breaks the cycle outright, because a
type-only import is erased and never loads the module at
all.
Extract a third module
Move whatever both of them need somewhere they can each import. Neither has to know about the other any more.
Merge them, or pass it in
If every function in one calls the other, they were one subject. Otherwise take the value as an argument — the caller already has both modules loaded.
Deep relative paths are unreadable and break whenever a file moves. Configure an alias:
Your bundler or runtime needs to know about the alias too — most
read it from tsconfig.json, and Node needs an equivalent entry
in package.json. Set it up once at the start of a project;
retrofitting means touching every import.
You can now split a program across files and folders with a small public surface each, and you know why an import that the checker accepts can still fail at runtime. The structural rule — split by subject, arrows pointing one way — is what keeps a growing project navigable.
Next is Configuring the Compiler, which is the file you have
been editing settings in without a proper explanation. It covers
what strict actually turns on, which of the remaining options
are worth the friction, and the ones that decide whether your
imports resolve.
Before you move on, take the longest file you have and split it
into two modules with an index.ts in front. Then deliberately
create a circular import between them and read the failure. That
error is confusing the first time and obvious once you have
caused it on purpose.
src/
├── types.ts every type in the project
├── utils.ts forty unrelated functions
├── constants.ts
└── main.tssrc/
├── captions/
│ ├── makeCaption.ts
│ └── types.ts
├── uploads/
│ ├── uploadPhoto.ts
│ └── types.ts
└── main.tsMODULES
any file with import or export
what you export is public; everything else is private to the file
types export the same way as values
EXTENSIONS
import { x } from "./captions.js"; the path is the OUTPUT
required with "type": "module" and modern Node
optional with a bundler
an import that checks fine and fails at runtime is usually this
EXPORTS
export function f() {} named <- prefer
export default function f() {} imported under ANY name
a default breaks rename-across-project and find-usages
import { f as g } from "..." rename
import * as mod from "..." everything under one name
export * from "..." <- avoid: hides your surface
TYPE-ONLY
import type { Photo } from "./photo.js";
import { type Photo, upload } from "./photo.js";
erased entirely; breaks cycles that only types caused
"verbatimModuleSyntax": true requires the marker
STRUCTURE
split by SUBJECT, not by what things are
never a types.ts / utils.ts / constants.ts for the whole project
utils becomes where uncategorisable things go to be forgotten
an index.ts per folder = a front door you can reorganise behind
CIRCULAR IMPORTS
symptom: undefined at module-evaluation time, order-dependent
fix: extract a third module, merge them, or pass it as an argument
import type breaks a type-only cycle outright
keep the dependency arrows pointing ONE way
PATHS
paths: { "@/*": ["src/*"] } in tsconfig
the bundler or runtime needs to know too
set it up at the start; retrofitting touches every importimport { makeCaption as caption } from "./captions.js"; // rename
import * as captions from "./captions.js"; // all of itexport * from "./captions.js"; // re-export everythingimport type { Photo } from "./photo.js";
import { type Photo, uploadPhoto } from "./photo.js";// src/captions/index.ts
export { makeCaption } from "./makeCaption.js";
export type { CaptionOptions } from "./types.js";import { makeCaption } from "./captions/index.js";// uploads.ts
import { makeCaption } from "./captions.js";
// captions.ts
import { uploadPhoto } from "./uploads.js";import { makeCaption } from "../../../captions/index.js";{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}import { makeCaption } from "@/captions/index.js";