TypeScript Foundation
Modules in TypeScript
Split a TypeScript project into files, import types with import type, see why verbatimModuleSyntax exists, write .js in import paths, and understand how NodeNext resolution, "type" - "module" and the "exports" field fit together.
A project with src and dist
You learned import and export in the JavaScript modules lesson. TypeScript uses the same syntax, adds a few rules for types, and then the compiled JavaScript has to work in Node.js. This lesson builds a small project that shows every rule. Make a new folder next to ts-tasks:
mkdir ts-modules cd ts-modules npm init -y npm pkg set type=module npm install -D typescript tsx @types/node added 7 packages, and audited 8 packages in 7s found 0 vulnerabilities npm warn install-scripts 1 package has install scripts not yet covered by allowScripts: npm warn install-scripts esbuild@0.28.2 (postinstall: node install.js) npm warn install-scripts npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow. mkdir src
This time the source files live in src/, and the compiled JavaScript will go to dist/. That is the usual layout of a real project, ZudoJS packages included. Two new settings tell tsc about it:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"verbatimModuleSyntax": true,
"rootDir": "src",
"outDir": "dist"
}
}
rootDir: where the.tsfiles are. The folder structure inside it is copied tooutDir.outDir: where compiled.jsfiles are written. There is nonoEmitthis time, because this project will be built.npx tsc --noEmitstill only checks.
Exporting and importing types
The first file holds the types and one value:
export type Status = "todo" | "doing" | "done";
export interface Task {
readonly id: number;
title: string;
status: Status;
}
export const STATUSES: readonly Status[] = ["todo", "doing", "done"];
You export a type exactly like a value. The difference is on the importing side. A type-only import is written import type:
import type { Status, Task } from "./task.js";
const tasks: Task[] = [];
export function addTask(title: string): Task {
const task: Task = { id: tasks.length + 1, title, status: "todo" };
tasks.push(task);
return task;
}
export function moveTask(id: number, status: Status): Task | undefined {
const task = tasks.find((t) => t.id === id);
if (task) task.status = status;
return task;
}
export function allTasks(): readonly Task[] {
return tasks;
}
When one import brings both a value and a type, mark just the type with type inside the braces:
import { STATUSES, type Task } from "./task.js";
export function board(tasks: readonly Task[]): string {
const lines = STATUSES.map((status) => {
const titles = tasks.filter((t) => t.status === status).map((t) => t.title);
return `${status}: ${titles.join(", ") || "-"}`;
});
return lines.join("\n");
}
Why the fuss? Remember that types are erased. STATUSES exists when the program runs; Task does not. With verbatimModuleSyntax on, TypeScript follows one simple rule: an import type is removed, and every other import stays exactly as written. So if you forget the type, the compiled file asks Node.js for an export that does not exist. The compiler stops you first:
import { STATUSES, Task } from "./task.js";
const task: Task = { id: 1, title: "Buy milk", status: "todo" };
console.log(task.title, STATUSES.length);
npx tsc --noEmit printssrc/oops.ts:1:20 - error TS1484: 'Task' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
1 import { STATUSES, Task } from "./task.js";
~~~~
Found 1 error in src/oops.ts:1And here is what happens if you run that file anyway, since tsx does not check types:
npx tsx src/oops.ts ~/ts-modules/src/oops.ts:1 import { STATUSES, Task } from "./task.js"; ^ SyntaxError: The requested module './task.js' does not provide an export named 'Task' at #asyncInstantiate (node:internal/modules/esm/module_job:327:21) … Node.js v24.19.0
STATUSES is a real export of task.js, but Task was an interface, and the compiled task.js has no trace of it. Without verbatimModuleSyntax, TypeScript would guess which imports are types and drop them silently. The guess is usually right, but other tools that strip types, such as tsx, Node.js and the browser terminal, look at one file at a time and cannot guess. Being explicit makes every tool agree. That is why ZudoJS turns it on, and why this course has used import type since Interfaces, unions and literal types.
A barrel file
Other code should not need to know which file each function lives in. A barrel is an index.ts that re-exports the public parts of a folder, so there is one place to import from. Types are re-exported with export type:
export type { Status, Task } from "./task.js";
export { STATUSES } from "./task.js";
export { addTask, allTasks, moveTask } from "./store.js";
export { board } from "./format.js";
Every ZudoJS package is built this way: each folder has an index.ts barrel, and import { schema } from "@zudojs/schema" reaches the top one. A barrel contains only exports, never logic. Now the program:
import { addTask, allTasks, board, moveTask } from "./index.js";
import type { Task } from "./index.js";
const first: Task = addTask("Buy milk");
addTask("Call Ada");
addTask("File taxes");
moveTask(first.id, "done");
moveTask(2, "doing");
console.log(board(allTasks()));
npx tsx src/main.ts and of the browser terminaltodo: File taxes doing: Call Ada done: Buy milk
Why the imports say .js
The files are called store.ts and index.ts, yet every import says "./store.js" and "./index.js". This surprises everyone once. The reason: TypeScript does not change import paths. It removes types, and leaves the rest of your code as you wrote it. Build the project and look:
npx tsc ls dist format.js index.js main.js store.js task.js cat dist/main.js import { addTask, allTasks, board, moveTask } from "./index.js"; const first = addTask("Buy milk"); addTask("Call Ada"); addTask("File taxes"); moveTask(first.id, "done"); moveTask(2, "doing"); console.log(board(allTasks())); node dist/main.js todo: File taxes doing: Call Ada done: Buy milk
dist/main.js imports "./index.js", and there is a dist/index.js next to it, so Node.js finds it. The import type line is gone completely. When tsc checks src/main.ts, it knows that ./index.js will be built from ./index.ts, and reads that file's types. You write the path of the file that will run.
The other spellings do not work with NodeNext:
import { addTask } from "./store";
import { board } from "./format.ts";
console.log(board([addTask("Buy milk")]));
npx tsc --noEmit printssrc/paths.ts:1:25 - error TS2835: Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './store.js'?
1 import { addTask } from "./store";
~~~~~~~~~
src/paths.ts:2:23 - error TS5097: An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
2 import { board } from "./format.ts";
~~~~~~~~~~~~~
Found 2 errors in the same file, starting at: src/paths.ts:1"./store"without an extension: Node's ES modules never guess file extensions, so neither does TypeScript. The error even suggests the fix."./format.ts":dist/main.jswould then import a.tsfile that does not exist indist. (Newer TypeScript can rewrite.tsto.jswhile building, with therewriteRelativeImportExtensionssetting. That is what you would use to run the same files withnode src/main.ts. This course sticks with.js, like ZudoJS.)- A folder name alone, like
"./tasks", does not load./tasks/index.jsin ES modules either. Write the full path.
Module resolution and "type": "module"
Module resolution is how a tool turns an import string into a file. "moduleResolution": "NodeNext" tells TypeScript to use exactly Node's rules, so anything that passes tsc also loads in Node.js:
- A path starting with
./or../is a file next to this one, with its full extension. - Anything else, like
"@zudojs/schema", is a package innode_modules, found through itspackage.json. - Whether a file is an ES module or old-style CommonJS depends on the nearest
package.json:"type": "module"means ES modules. The file extensions.mtsand.ctsforce one or the other, whateverpackage.jsonsays.
See what happens when the "type" line is missing:
npm pkg delete type npx tsc --noEmit src/format.ts:1:10 - error TS1295: ECMAScript imports and exports cannot be written in a CommonJS file under 'verbatimModuleSyntax'. Adjust the 'type' field in the nearest 'package.json' to make this file an ECMAScript module, or adjust your 'verbatimModuleSyntax', 'module', and 'moduleResolution' settings in TypeScript. 1 import { STATUSES, type Task } from "./task.js"; ~~~~~~~~ … Found 15 errors in 5 files. Errors Files 2 src/format.ts:1 5 src/index.ts:2 4 src/main.ts:1 3 src/store.ts:5 1 src/task.ts:9 npm pkg set type=module npx tsc --noEmit
Without "type": "module", Node.js treats every .js file as CommonJS, so TypeScript does too, and import/export are not allowed. Fifteen errors, one fix: put the line back. Whenever you see TS1295 or TS1287, check package.json first.
Packages and the "exports" field
When you import a package, TypeScript and Node.js read its package.json. Modern packages list their public entry points in the "exports" field. Here is the relevant part of @zudojs/schema's package.json, the package you will install in Your first Zudo code:
{
"name": "@zudojs/schema",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
}
"."is the package's main entry:import … from "@zudojs/schema"."types"is the file TypeScript reads. A.d.tsfile (a declaration file) holds only types;tscwrites one next to each.jsfile when a package is built with thedeclarationsetting."import"is the file Node.js runs for animport.
Anything not listed in "exports" is private to the package, even though the file is right there in node_modules. Reaching into it is refused by both tools:
import { schema } from "@zudojs/schema/dist/schemaRoot/index.js";
console.log(schema.string().parse("Buy milk"));
npx tsc --noEmit printsdeep-import.ts:1:24 - error TS2307: Cannot find module '@zudojs/schema/dist/schemaRoot/index.js' or its corresponding type declarations.
1 import { schema } from "@zudojs/schema/dist/schemaRoot/index.js";
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in deep-import.ts:1Node.js reports the same thing at runtime as ERR_PACKAGE_PATH_NOT_EXPORTED. That is a feature: a package author can reorganise internal files without breaking your code, because you could only ever use what "exports" promised. Import from the package name.
Scripts for check, build and run
Put the commands in package.json so nobody has to remember them. With npm pkg set, or by editing the file, add four scripts:
{
"name": "ts-modules",
"version": "1.0.0",
"type": "module",
"scripts": {
"check": "tsc --noEmit",
"build": "tsc",
"start": "node dist/main.js",
"dev": "tsx src/main.ts"
},
"devDependencies": {
"@types/node": "^26.6.2",
"tsx": "^4.23.15",
"typescript": "^7.0.2"
}
}
npm run check > ts-modules@1.0.0 check > tsc --noEmit npm run build > ts-modules@1.0.0 build > tsc npm start > ts-modules@1.0.0 start > node dist/main.js todo: File taxes doing: Call Ada done: Buy milk
npm run dev runs the source with tsx while you work; check and build before you ship; start runs the built JavaScript, which is what a server does in production. A project created by the ZudoJS CLI comes with scripts like these; there, the check script is called typecheck. Add dist/ to your .gitignore, as you learned in the Git lesson: it is generated, so it does not belong in the repository.
Practice
TRY IT YOURSELF
Add a module
Add src/stats.ts with a function countByStatus(tasks: readonly Task[]) that returns a Record<Status, number>. Export it from the barrel, use it in main.ts, and check that npm run check and npm run build pass.
Show a solution
export type Status = "todo" | "doing" | "done";
export interface Task {
readonly id: number;
title: string;
status: Status;
}
import type { Status, Task } from "./task.js";
export function countByStatus(tasks: readonly Task[]): Record<Status, number> {
const counts: Record<Status, number> = { todo: 0, doing: 0, done: 0 };
for (const task of tasks) counts[task.status] += 1;
return counts;
}
import { countByStatus } from "./stats.js";
import type { Task } from "./task.js";
const tasks: Task[] = [
{ id: 1, title: "Buy milk", status: "done" },
{ id: 2, title: "Call Ada", status: "todo" },
{ id: 3, title: "File taxes", status: "todo" },
];
console.log(countByStatus(tasks));
npx tsx src/main.ts and of the browser terminal{ todo: 2, doing: 0, done: 1 }In your project, add export { countByStatus } from "./stats.js"; to src/index.ts and import it from "./index.js" in main.ts. stats.ts only needs import type, because it uses Task and Status only as types.
TRY IT YOURSELF
Which imports are wrong?
With NodeNext and verbatimModuleSyntax, which of these lines does tsc reject, and how do you fix each one?
import { Task } from "./task.js";
import { addTask } from "./store.js";
import { board } from "./format";
import type { STATUSES } from "./task.js";
import { schema } from "@zudojs/schema";Show a solution
- Line 1 is rejected (TS1484):
Taskis a type. Writeimport type { Task }. - Line 2 is correct.
- Line 3 is rejected (TS2835): write
"./format.js". - Line 4 compiles, but
STATUSEScan then only be used as a type:STATUSES.map(…)would be an error, because animport typeis removed before the code runs. A value needs a normal import. - Line 5 is correct, once the package is installed: it uses the package's
"exports"entry.
Recap
- Import types with
import type(ortypeinside the braces), and re-export them withexport type.verbatimModuleSyntaxenforces it, so every tool agrees on what is removed. - TypeScript never changes import paths. Write the
.jsname of the file that will run;tscmaps it to the.tsfile. NodeNextfollows Node's rules: full relative paths, packages throughnode_modules, and"type": "module"for ES modules.- A package's
"exports"field lists what you may import; everything else is private. rootDir/outDirseparatesrc/fromdist/;check,build,startanddevscripts run the whole workflow.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.