Modules and declarations

Move types between files, and give an untyped package a shape.

12of 14
≈ 11Min read
8Parts

Types travel with imports

import type — or the inline type modifier — says the import exists only for the checker. The line is erased, which means it can never cause a circular import at runtime or keep a module alive in the bundle.

door.ts, then the file that uses it
// door.ts
export interface Door { id: string; name: string; price: number }
export type Finish = "painted" | "oiled";
export function describe(door: Door): string { … }

// order.ts
import { describe, type Door } from "./door.js";
import type { Finish } from "./door.js";

verbatimModuleSyntax

the flag that makes the rule explicit
// with verbatimModuleSyntax: true
import { Door } from "./door.js";        // Error if Door is only a type
import type { Door } from "./door.js";   // correct
Turn it on

It removes all the guessing about which imports survive compilation — and with a bundler that transpiles file by file, that guessing is where the confusing build errors come from.

A file with no import or export is not a module

and everything in it is global
// utils.ts — no import, no export
interface Door { … }          // now a GLOBAL type, colliding with every other Door

export {};                     // one line, and the file is a module again
The collision you will meet

"Duplicate identifier" or "Cannot redeclare block-scoped variable" in a file that looks fine almost always means it is a script rather than a module. Global names also collide with the DOM: Range, Event, Text and Location are all taken.

Default or named

Named exports autocomplete, rename safely and can be found by a text search. A default export is right when a file has exactly one thing in it and a tool expects it — a route module, a component file in some conventions.

prefer named
export default function describe(door: Door) { … }   // renamed freely on import
export function describe(door: Door) { … }            // the name is the contract

Declaration files

A .d.ts says what a shape is without producing any JavaScript. You write them by hand for untyped code, and generate them for a package you ship.

types with no implementation
// door.d.ts — describes something that already exists
export interface Door { id: string; name: string }
export declare function describe(door: Door): string;

// generated from your source with "declaration": true, for a library you publish

A package with no types

three answers, in order of preference
npm i -D @types/the-package          // 1. someone has already written them

// 2. src/types/the-package.d.ts — write the part you use
declare module "the-package" {
  export function slugify(input: string, options?: { lower?: boolean }): string;
}

// 3. the escape hatch, when you are in a hurry
declare module "the-package";        // everything from it is any
The third one is a promise to come back

It compiles, and it turns the checker off for everything that package touches. Write the three functions you actually call instead — it takes five minutes.

Global declarations

when something really is global
// env.d.ts
declare global {
  interface Window { __analytics?: (event: string) => void; }
}

export {};      // required: without it this file is not a module and "global" is an error

Paths and resolution

That last line surprises everyone: with native ESM in Node you import the output path, so a TypeScript file imports its neighbour as .js. With a bundler and moduleResolution: "bundler", you do not.

aliases, and the extension question
{
  "compilerOptions": {
    "moduleResolution": "bundler",
    "paths": { "@/*": ["./src/*"] }
  }
}

import { Door } from "@/model/door";        // with a bundler
import { Door } from "./model/door.js";     // with Node: the .js extension, even from .ts

Practice