Learn / TypeScript
LESSON 39 OF 84

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.

  • 40 min to read and try
  • You need: Classes in TypeScript, and the JavaScript modules lesson
  • You build: A multi-file ts-modules project with src/ and dist/ folders, a barrel file and npm scripts to check, build and run it
Test yourself

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:

Terminal on your computer
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:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2024",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"],
    "verbatimModuleSyntax": true,
    "rootDir": "src",
    "outDir": "dist"
  }
}
  • rootDir: where the .ts files are. The folder structure inside it is copied to outDir.
  • outDir: where compiled .js files are written. There is no noEmit this time, because this project will be built. npx tsc --noEmit still only checks.

Exporting and importing types

The first file holds the types and one value:

src/task.ts
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:

src/store.ts
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:

src/format.ts
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:

src/oops.ts
import { STATUSES, Task } from "./task.js";

const task: Task = { id: 1, title: "Buy milk", status: "todo" };
console.log(task.title, STATUSES.length);
What npx tsc --noEmit prints
src/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:1

And here is what happens if you run that file anyway, since tsx does not check types:

Terminal on your computer
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:

src/index.ts
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:

src/main.ts
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()));
Output of npx tsx src/main.ts and of the browser terminal
todo: 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:

Terminal on your computer
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:

src/paths.ts
import { addTask } from "./store";
import { board } from "./format.ts";

console.log(board([addTask("Buy milk")]));
What npx tsc --noEmit prints
src/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.js would then import a .ts file that does not exist in dist. (Newer TypeScript can rewrite .ts to .js while building, with the rewriteRelativeImportExtensions setting. That is what you would use to run the same files with node src/main.ts. This course sticks with .js, like ZudoJS.)
  • A folder name alone, like "./tasks", does not load ./tasks/index.js in 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 in node_modules, found through its package.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 .mts and .cts force one or the other, whatever package.json says.

See what happens when the "type" line is missing:

Terminal on your computer
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:

package.json
{
  "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.ts file (a declaration file) holds only types; tsc writes one next to each .js file when a package is built with the declaration setting.
  • "import" is the file Node.js runs for an import.

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:

deep-import.ts
import { schema } from "@zudojs/schema/dist/schemaRoot/index.js";

console.log(schema.string().parse("Buy milk"));
What npx tsc --noEmit prints
deep-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:1

Node.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:

package.json
{
  "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"
  }
}
Terminal on your computer
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
src/task.ts
export type Status = "todo" | "doing" | "done";

export interface Task {
  readonly id: number;
  title: string;
  status: Status;
}
src/stats.ts
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;
}
src/main.ts
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));
Output of 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): Task is a type. Write import type { Task }.
  • Line 2 is correct.
  • Line 3 is rejected (TS2835): write "./format.js".
  • Line 4 compiles, but STATUSES can then only be used as a type: STATUSES.map(…) would be an error, because an import type is 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 (or type inside the braces), and re-export them with export type. verbatimModuleSyntax enforces it, so every tool agrees on what is removed.
  • TypeScript never changes import paths. Write the .js name of the file that will run; tsc maps it to the .ts file.
  • NodeNext follows Node's rules: full relative paths, packages through node_modules, and "type": "module" for ES modules.
  • A package's "exports" field lists what you may import; everything else is private.
  • rootDir/outDir separate src/ from dist/; check, build, start and dev scripts 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.