Skip to main content
CodeOath
← All posts

TypeScript63 min total · 21 parts

TypeScript Fundamentals: Types, Interfaces, Generics, and Why It Catches Bugs Before Runtime

Part 18 of 21 · ~2 min

Modules and Declaration Files

Once a file has even one top-level import or export in it, it stops sharing scope with the rest of the project the way a plain, un-bundled script tag would — everything inside it is private by default unless explicitly exported. Imports and exports can also be marked type-only, and that marking isn't cosmetic for every build setup: turn on isolatedModules and each file gets compiled on its own, with no visibility into any other file, so the compiler has no way to know whether a given import exists only for a type (and can be deleted with zero runtime consequence) unless the type keyword says so directly.

// types.ts
export interface Applicant { id: number; name: string; email: string; stage: Stage; }

// queue.ts
import type { Applicant } from "./types"; // gone entirely after compilation — no runtime import at all
import { fetchApplicant } from "./api";   // a real, ordinary runtime import

A .d.ts file is pure description — types with no function bodies behind them, meant to tell the compiler about JavaScript code that exists somewhere else and was never written in TypeScript to begin with. CodeOath's resume parser is exactly that kind of code: an older plain-JS library, no published types anywhere, so someone wrote a small .d.ts for it by hand rather than rewriting the library itself:

// resume-parser.d.ts
declare function parseResume(fileBuffer: Buffer): {
  rawText: string;
  detectedSkills: string[];
};

Most libraries you'd actually reach for don't need this treatment — either the library ships its own types, or someone's already published a matching package under the @types/ scope on npm, and installing it is the entire fix. Hand-writing a .d.ts from scratch is what's left once neither of those is true: a plain-JS dependency small or obscure enough that nobody's bothered to type it yet.

A .d.ts file can also extend a module that already has types, rather than describing a brand-new one — the same merging behavior from the interface vs. type chapter, aimed deliberately at someone else's library this time. The queue's session middleware attaches the current reviewer to Express's request object, a field the published Express types have no idea exists:

// express-augment.d.ts
import "express";

declare module "express" {
  interface Request {
    reviewer?: Reviewer;
  }
}

app.get("/queue/next", (req, res) => {
  console.log(req.reviewer?.email); // typed correctly, though Express itself never declared this
});

Same mechanism that caused the accidental ReviewSession collision a few chapters back, deployed on purpose this time: the declaration doesn't overwrite Express's own Request, it folds into it.