Skip to Content
TypeScript Intermediate: Types That Survive Real Projects
Intermediate
0 likes
Courses
TypeScript Intermediate: Types That Survive Real Projects

Modules, Exports and Avoiding Circular Types

Export and import with import type, see why two files that import each other fail at run time and break the loop with a third file. After this, shared types and values have a place that does not create a cycle.

Course content

Sign in to save your progress.

Course Home

Skip ahead: If you already use import type and know why two files that import each other can fail at run time, skip to "Two files that import each other", then continue.

Splitting code into files is where most TypeScript projects start to hurt. A type that two files need ends up living in one of them, the other imports it, and a few weeks later the first file imports something from the second. Now you have a loop. This lesson is about writing exports so that loops are rare, and about what to do when you find one. All the examples run as ES modules, so set that once in the project.

npm pkg set type=module

Exports and imports

A file with an import or an export is a module. Everything in it is private to the file unless you export it.

export type Money = { amount: number; currency: string };

export function formatMoney(value: Money): string {
  return `${value.currency} ${value.amount.toFixed(2)}`;
}
import { formatMoney, type Money } from "./money.js";

const price: Money = { amount: 1250, currency: "PHP" };
console.log(formatMoney(price));
PHP 1250.00

Two details are worth noticing. The import path ends in .js, even though the file you wrote is money.ts. Node runs the compiled JavaScript, and it needs the real file name, so TypeScript lets you write the name of the output. And type Money inside the braces marks that one name as a type.

import type and export type

A type has no run-time existence. tsc deletes it. If you import only types, the whole import line should disappear from the output. The type keyword makes that visible and enforceable.

export type Order = { id: number; total: number };
export type Customer = { name: string; orders: Order[] };
npx tsc types.ts
cat types.js
export {};

The compiled types.js has nothing in it. A file that only exports types costs nothing at run time. Now turn on the stricter setting, verbatimModuleSyntax, which is also in the tsconfig that tsc --init writes. It refuses an import that is only used as a type unless you mark it.

import { Order } from "./types.js";

export function total(orders: Order[]): number {
  return orders.reduce((sum, order) => sum + order.total, 0);
}
report.ts(1,10): error TS1484: 'Order' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.

Fix it by writing import type { Order } from "./types.js". That is the line to prefer in every file where the name is only used in a type position. It tells the reader and the build tool that nothing from that file is needed at run time.

Two files that import each other

Types can point at each other freely, because they vanish. Values cannot.

import { bName } from "./b.js";

export const aName = "A";
console.log("a sees", bName);
import { aName } from "./a.js";

export const bName = "B";
console.log("b sees", aName);
import "./a.js";

The compiler reports nothing. There is no type error, because the types are fine. Run it.

npx tsc loop.ts
node loop.js 2>&1 | grep ReferenceError
ReferenceError: Cannot access 'aName' before initialization

Loading a starts loading b first. b runs and reads aName before a has reached the line that creates it. The result is a ReferenceError at start-up, and the grep above keeps only that line of the longer message. Nothing in your editor warned you.

⚠ Warning: A circular import often works by accident, with function declarations and no top-level code that reads the other file. Then one day somebody adds a const and it breaks. Do not rely on that.

The fix is a third file

Almost every cycle has the same shape. Two files each need something small that belongs to neither. Move that thing out.

export const aName = "A";
export const bName = "B";
import { aName, bName } from "./names.js";

console.log("a sees", bName, "and owns", aName);
import { aName, bName } from "./names.js";

console.log("b sees", aName, "and owns", bName);
import "./a.js";
import "./b.js";
a sees B and owns A
b sees A and owns B

Both files depend on names.ts, and names.ts depends on nothing. Dependencies point one way, so there is no loop. Shared types do the same job: put them in a types.ts, import them with import type, and neither feature file needs the other.

Recursive types are not the problem

A type may refer to itself. That is not a cycle in the sense above.

type Category = { name: string; children: Category[] };

const shop: Category = {
  name: "Shop",
  children: [{ name: "Pens", children: [] }],
};

function names(category: Category): string[] {
  return [category.name, ...category.children.flatMap(names)];
}

console.log(names(shop));
[ 'Shop', 'Pens' ]

Category inside Category is how you describe a tree. The trouble is only with files that import each other's values.

Common mistakes

  • Using export default everywhere. A named export gives the same name in every file and an error when you misspell it.
  • Writing import { Order } for a type and letting the build tool guess whether to keep it.
  • Leaving off .js in a relative import under ES modules, then getting "cannot find module" at run time.
  • Putting a type in the file that happens to use it first, instead of in a file of its own.

💡 Tip: When you cannot decide where a type lives, ask who else will need it. If the answer is "probably another feature", it goes in a shared file now.

If you skip this, you will end up with a cycle that works on your machine and fails in the build, and the error will point at a line that looks fine. Which two files in your project import each other right now?

About

Export and import with import type, see why two files that import each other fail at run time and break the loop with a third file. After this, shared types and values have a place that does not create a cycle.

Duration
5 min
Last update
10/17/2026
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.