TypeScript · Lesson 8 of 12

Enums, Modules and Declaration Files

Understand TypeScript enums vs as const, ES module imports and type-only imports, and how .d.ts declaration files and @types packages type JavaScript.

  • Intermediate
  • 16 min read
  • 4 objectives

Before this lessonLesson 7: Mapped, Conditional and Template Literal Types

What you will learn

  • Decide between enums, const enums and as const objects
  • Use ES modules with import type and verbatimModuleSyntax
  • Read and write .d.ts declaration files
  • Type untyped packages and global variables

Your Progress

0 of 12 lessons 0%

  • Lessons0 / 12
  • Completed0
  • Est. time left~ 3 hours

Create a free account to keep your progress on every device.

Tip: pressing Next marks this lesson complete automatically.

Real projects are split across many files and depend on packages written by other people, often in plain JavaScript. This lesson covers how TypeScript organises code into modules, how it knows the types of JavaScript libraries (declaration files), and a feature you will see in older codebases: enums.

Enums and what they compile to

An enum names a fixed set of values. Unlike almost every other TypeScript feature, enums are not erased: they generate a real JavaScript object. Numeric enums even create a reverse mapping from number to name.

enum Direction { Up, Down }             // numeric: Up = 0, Down = 1
enum Status { Active = "active", Banned = "banned" }  // string enum

function move(d: Direction) { /* ... */ }
move(Direction.Up);

This JavaScript is essentially what the compiler emits, so you can see the extra runtime object:

var Direction;
(function (Direction) {
  Direction[Direction["Up"] = 0] = "Up";
  Direction[Direction["Down"] = 1] = "Down";
})(Direction || (Direction = {}));

console.log(Direction);
console.log(Direction.Up, Direction[0]);
Output
{ '0': 'Up', '1': 'Down', Up: 0, Down: 1 }
0 Up

Numeric enums also have a loose spot: historically any number was assignable to them. That, plus the generated code, is why most 2026 style guides prefer a union of string literals, or an as const object when you want a named value to refer to.

export const Status = {
  Active: "active",
  Banned: "banned",
} as const;

export type Status = (typeof Status)[keyof typeof Status];   // "active" | "banned"

function setStatus(s: Status) { /* ... */ }
setStatus(Status.Active);   // OK, reads like an enum
setStatus("banned");        // also OK: it is just a string union

The object and the type share a name, which is legal because values and types live in separate namespaces. You get enum-like ergonomics with zero TypeScript-only runtime code.

ES modules

Any file with a top-level import or export is a module: its variables are private unless exported. TypeScript uses standard ES module syntax and adds one idea on top, importing only a type.

// models.ts
export interface Order { id: string; total: number; }
export const TAX_RATE = 0.18;
export default function calcTax(o: Order) { return o.total * TAX_RATE; }

// checkout.ts
import calcTax, { TAX_RATE } from "./models.js";   // values
import type { Order } from "./models.js";          // type only, erased from output
import { type Order as O2, TAX_RATE as rate } from "./models.js"; // inline form

Why mark type imports? When the compiler (or a fast tool like esbuild, SWC or Node's type stripper) handles one file at a time, it cannot always tell whether Order is a type or a value. import type removes the guesswork, and the verbatimModuleSyntax option enforces it: imports without type are kept, imports with it are dropped. It is on by default in a fresh tsc --init.

Notice the .js extension in the import path of a .ts file. With "module": "nodenext", you write the path of the file that will exist after compilation. With a bundler (Vite, Next.js) and "moduleResolution": "bundler", you can omit extensions. Newer setups can also enable allowImportingTsExtensions and import ./models.ts directly.

Declaration files (.d.ts)

A declaration file contains only types: no implementations. It describes the shape of JavaScript code so TypeScript can check calls into it. When you compile with "declaration": true, TypeScript writes one next to each output file, which is how published libraries ship types.

// dist/models.d.ts (generated by tsc)
export interface Order {
    id: string;
    total: number;
}
export declare const TAX_RATE = 0.18;
export default function calcTax(o: Order): number;

The declare keyword means "this exists at runtime, trust me, here is its type". You rarely write whole .d.ts files by hand, but you will read them constantly: ctrl/cmd-clicking a library function in your editor usually opens one.

Where library types come from

  • Bundled: most modern packages (zod, axios, date-fns) ship their own .d.ts, listed under "types" or "exports" in their package.json. Nothing to install.
  • DefinitelyTyped: for packages written in JavaScript without types, the community publishes @types/<name>. For example npm i -D @types/express @types/node.
  • Your own: if neither exists, write a small declaration yourself.
npm install express
npm install -D @types/express @types/node

One recent change to know: since TypeScript 6.0 a new tsconfig sets "types": [], so global type packages like @types/node are not loaded automatically. If process or Buffer is suddenly unknown, add "types": ["node"].

Typing untyped modules and globals

When a package has no types, importing it gives an error like Could not find a declaration file for module. Create a .d.ts file inside your project (for example src/types/legacy.d.ts) and describe just the parts you use. The same technique types global variables injected by a script tag, and extends existing interfaces such as Window.

// src/types/legacy.d.ts
declare module "legacy-slugify" {
  export default function slugify(text: string, opts?: { lower?: boolean }): string;
}

// Global injected by an analytics <script> tag
declare const ANALYTICS_ID: string;

// Add a property to the built-in Window interface
interface Window {
  stackcone?: { track(event: string): void };
}

Adding to Window works because interfaces with the same name merge. This declaration merging is also how libraries let you extend their types (for example adding a user property to Express's Request). Note that in a file that is itself a module, you must wrap such additions in declare global { ... }.

Recap

  • Enums generate runtime code; prefer string literal unions or as const objects in new code.
  • Use import type for type-only imports; verbatimModuleSyntax enforces it for single-file tools.
  • .d.ts files hold types only; declare describes something that exists at runtime.
  • Library types come bundled, from @types/*, or from a small declaration you write yourself.
  • Interfaces merge, which lets you extend Window or library types via declare global.
// Write your solution here

Finished reading? Mark this lesson complete to track your progress.

Up next · Lesson 9Typing Async Code, fetch and API ResponsesType TypeScript async functions and Promises, wrap fetch in a typed API client, handle errors with Result types and validate JSON at runtime with zod.