Classes, modules and configuration Foundation
Modules in TypeScript
Split a project into files, import types with import type, write .js in import paths, and see how NodeNext, "type": "module" and "exports" fit together.
BY THE END OF THIS LESSON YOU CAN
- Lay out a project with src and dist using rootDir and outDir
- Import and re-export types with import type and export type, and explain TS1484
- Write .js in relative import paths and explain why TypeScript never rewrites them
- Explain how NodeNext, "type": "module" and a package's "exports" field decide what an import loads
- Set up check, build, start and dev scripts
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 [email protected] (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 What the TypeScript compiler does.
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
REASON IT OUT
Which file will the import load?
src/main.ts imports from src/index.ts. After npx tsc, Node.js will run dist/main.js. Before reading on, think:
- Which files exist in
dist/after the build, and which do not? - Node.js reads the import string in
dist/main.jsliterally. Which file name must that string contain for the import to work? - Does
tscchange import strings when it removes the types? - So which name should you write in
src/main.ts:./index,./index.tsor./index.js?
Show the reasoning
- Only
.jsfiles (and.d.tsor maps if you ask for them). No.tsfile is copied todist. ./index.js: Node's ES modules never add or guess an extension.- No. It removes types and leaves the rest of the code, import paths included, as you wrote it.
./index.js, the file that will exist when the code runs.tscknows that./index.jsis built from./index.tsand reads the types from there.
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 whose safeParse you tried in Generics and which Your first Zudo code installs:
{
"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 > [email protected] check > tsc --noEmit npm run build > [email protected] build > tsc npm start > [email protected] 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.
Write it in the editor, then press Check. Hints and the solution open up once you have checked your code.
HINT 1
In stats.ts, start const counts: Record<Status, number> = { todo: 0, doing: 0, done: 0 };, then for (const task of tasks) counts[task.status] += 1;, the same as tallyHealth above.
HINT 2
In main.ts, add import { countByStatus } from "./stats.js"; at the top, and console.log(countByStatus(tasks)); at the end.
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";Work it out first, on paper or in your head. Then use the hints, and compare with the solution.
HINT 1
For each line, ask two questions: is the imported name a type or a value, and does the path end in .js with the extension written out?
HINT 2
An import type line is removed entirely before the code runs, so anything inside it becomes unavailable as a value, even if the name refers to something that really exists at runtime.
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.
Next: tsconfig in depth, every compiler option you will meet in a real project, and the bug each one prevents.
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.