Chapter 17 of 24 · TypeScript for Beginners — Complete Free Tutorial
Modules, Declaration Files and d.ts
Everything you have typed so far lived in one file. Real projects do not: code splits into modules, some of it is JavaScript somebody else wrote, and the compiler needs a way to know about both. Chapter 17 of this 24-chapter series covers ES module syntax in TypeScript, type-only imports, and the mysterious .d.ts files that let TypeScript describe code it cannot see inside.
ES modules in TypeScript
TypeScript uses the same ES module syntax you may know from the JavaScript modules chapter: every file with an import or export is a module, and everything else is a script whose declarations leak into the global scope. Named exports and default exports both work unchanged.
// src/utils/format.ts
export interface FormatOptions {
uppercase: boolean;
}
export function formatName(name: string, options: FormatOptions): string {
return options.uppercase ? name.toUpperCase() : name;
}
export default function greet(name: string): string {
return `Hello, ${name}`;
}
// src/main.ts
import greet, { formatName } from './utils/format';
import type { FormatOptions } from './utils/format';
const opts: FormatOptions = { uppercase: true };
console.log(formatName('ada', opts), greet('Ada'));
Expected output: ADA Hello, Ada
The resolution rules are the compiler's, not the runtime's: extensionless relative imports work because moduleResolution in tsconfig.json tells TypeScript how to find ./utils/format. If a file must be a module but exports nothing (a file of helpers, or one that only augments globals), end it with export {};.
type imports vs value imports
An interface exists only in the type world, so a plain import { FormatOptions } describes a value that will not exist after compilation. TypeScript gives you two dedicated forms — import type and export type (plus the inline modifier import { type X }) — and they carry a guarantee: they are always erased, no matter how your project is configured.
import type { FormatOptions } from './utils/format'; // erased completely
import { formatName } from './utils/format'; // kept in the emitted JS
export type { FormatOptions }; // type-only re-export, erased
export { formatName }; // real re-export, kept
// inline form, handy when only one name is a type:
import { type FormatOptions, formatName } from './utils/format';
Expected output: with "verbatimModuleSyntax": true in your config (a TypeScript 5.x option that emits imports exactly as written), a value-style import of a type is rejected outright; and under "isolatedModules", re-exporting a type the naive way fails too:
error TS1484: 'FormatOptions' is a type and must be imported using a type-only import when 'verbatimModuleSyntax' is enabled.
error TS1205: Re-exporting a type when 'isolatedModules' is enabled requires using 'export type'.
These messages are not style nags. Tools that compile file-by-file (bundlers, tsc --transpileOnly, SWC) cannot know that FormatOptions is a type, so they would emit an import to a binding that does not exist at runtime. import type removes the guesswork.
What TypeScript knows vs what JavaScript runs
Everything so far points at one rule worth stating plainly: types are erased. The compiler reads your annotations, checks them, then throws them away — and only a few constructs leave a mark on the JavaScript.
| TypeScript | In the emitted JavaScript | Why |
|---|---|---|
interface FormatOptions { … } | nothing | Pure type, erased |
type Id = string | number | nothing | Pure type, erased |
import type { X } from './y' | nothing | Guaranteed erasure |
import { helper } from './y' | the import statement | A real runtime dependency |
class User implements Shape | the class only | implements is checked, then erased |
enum Role { Admin, Guest } | a small object with both members | Rare case where a type creates runtime code |
declare const VERSION: string | nothing | Ambient declarations never emit |
This is why instanceof against an interface is impossible — the interface is not there at runtime, and the compiler says so: error TS2693: 'Ghost' only refers to a type, but is being used as a value here.
Declaration files: shape without code
A .d.ts file is a TypeScript file containing only declarations. It never runs, never emits, and its job is to describe what JavaScript would expose. The declare keyword marks the statement as ambient: this exists somewhere else; just believe the signature. Two scenarios come up constantly: shipping types alongside a plain JavaScript library, and describing an untyped package you consume.
// src/format.js - plain JavaScript you ship to consumers
export function formatBytes(bytes, digits = 1) {
return `${(bytes / 1024).toFixed(digits)} KB`;
}
// src/format.d.ts - what TypeScript should believe about it
export declare function formatBytes(bytes: number, digits?: number): string;
// consumer.ts
import { formatBytes } from './format';
console.log(formatBytes(2048), formatBytes(2048, 0));
Expected output: 2.0 KB 2 KB, while formatBytes('lots') fails with error TS2345: Argument of type '"lots"' is not assignable to parameter of type 'number'.
The sibling .d.ts wins resolution over the .js file, so callers get checking while users of the library get JavaScript. One rule: never mix implementations into a .d.ts — a function without declare would be code, and this file must stay pure description.
Ambient declarations and declare module
When a package has no types of its own, you write an ambient module declaration: a declare module 'name' block that tells TypeScript exactly what the untyped module exports. Global constants use the same idea without the module wrapper, and declare global is how a module file augments the global scope.
// types/tiny-slider.d.ts
declare module 'tiny-slider' {
export interface Options { loop?: boolean; speed?: number }
export default function slider(node: HTMLElement, options?: Options): { destroy(): void };
}
declare const __APP_VERSION__: string;
declare module '*.svg' {
const src: string;
export default src;
}
// app.ts
import slider from 'tiny-slider';
const controls = slider(document.body, { loop: true });
controls.destroy();
console.log(__APP_VERSION__);
slider(document.body, { speed: 'fast' }); // compile error
Expected output: error TS2322: Type 'string' is not assignable to type 'number'.
Note the wildcard module (*.svg) — it types every image import project-wide, which is how bundlers let you import assets. These files are included automatically because they sit in your include globs; nothing imports them directly.
@types packages, skipLibCheck and the types option
For popular packages the community publishes types in @types/* packages: @types/node, @types/lodash and so on. TypeScript finds anything under node_modules/@types automatically, and three tsconfig.json options control that behaviour:
{
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"moduleResolution": "bundler",
"verbatimModuleSyntax": true
}
}
types— once you list packages here, only those@types/*folders load automatically; leave it out to include all of them. Remove a package you rely on and its globals vanish (a common reasonprocessorimport.meta.envsuddenly errors).skipLibCheck— skips type checking of every.d.tsfile, including dependencies'. It speeds builds up dramatically and is the default in Vite's templates; turning it off means debugging other people's declarations.moduleResolution— decides how extensionless imports such as'./utils/format'resolve;bundlermatches what Vite and webpack do.
Common mistakes
- Importing a type with a plain
importunderverbatimModuleSyntax— the fix is one word:import type(or the inlinetypemodifier). - Expecting an interface at runtime —
instanceof MyInterfaceandconsole.log(MyInterface)cannot work; types are erased, so narrow with a value check instead. - Writing declarations for something that already ships them — check for the package itself first, then
@types/…; a hand-written duplicate will fight the real types. - Forgetting
export {}— a helper file with no imports or exports is a script, and its interfaces quietly join the global namespace, colliding with the next file that declares the same name. - Over-restricting
types— listing"types": ["node"]drops every other automatic package, including bundler globals such asvite/client; add back exactly what you use.
Practice checklist
- Split one file into two modules, move the interface with
export type, and confirm both files still compile. - Turn on
verbatimModuleSyntax, break an import on purpose, and read the TS1484 error before fixing it. - Write a
.d.tsfor a small JavaScript helper of your own and verify a wrong argument type is rejected. - Create an ambient
declare modulefor an untyped package (or a fake one) and watch autocomplete fill in its options. - Run
tscwithskipLibCheckoff once, skim the dependency errors it finds, then turn it back on.
What's next?
Modules often hide work that finishes later. The final chapter of this half of the series types promises, async and fetch in Typing Async Code: Promises and Fetch.
Frequently Asked Questions
TypeScript is a strict superset of JavaScript that adds a static type system. You annotate the promises your data makes, the compiler checks every one before the code runs, then erases the types and emits ordinary JavaScript. You catch wrong arguments, typoed properties and forgotten null checks while you are still typing, and refactoring plus editor autocomplete both improve sharply.
You should. This series assumes you can already read and write JavaScript: functions, arrays and objects, closures, modules and async/await. TypeScript audits that knowledge rather than replacing it, and types cannot rescue code you do not understand. If those fundamentals feel shaky, work through the JavaScript for Beginners series on this site first, and this course will go much faster.
No. TypeScript is compiled, not interpreted: tsc parses your file, type-checks it, then erases every annotation and writes ordinary JavaScript. The browser and Node.js only ever run that emitted .js, so runtime behaviour, performance and debugging are unchanged. It also never validates API data at runtime, so the series teaches you to distrust every boundary that leaves the compiler.
If you already know JavaScript, TypeScript is a modest step rather than a new language: the syntax is the same plus a colon. Most beginners read their first red squiggle within days and feel comfortable in two to four weeks of regular practice. The curve steepens later around generics and advanced types, which is where this 24-chapter series deliberately spends its second half.
Both name the same object shapes, so either works for a simple contract. The differences decide the rest: only interfaces merge when declared twice, while only type aliases can express unions, primitives, tuples and mapped types. The series default is interface for props and exported shapes other files consume, and type for everything else. Pick one style per project and stay consistent.
strict: true is a bundle of safety switches, not a single behaviour. It turns on noImplicitAny, so every parameter must be inferrable, strictNullChecks, so null and undefined are their own types you must narrow, strictFunctionTypes, noImplicitThis, strictPropertyInitialization, and useUnknownInCatchVariables, which types catch values as unknown. Every example in this series compiles with it on.
any accepts anything and switches the compiler off, so one any spreads through inference into everything derived from it and you are back to dynamic typing with a .ts extension. unknown accepts anything too, but forbids property access until you narrow it with a typeof or instanceof check. The series pushes you toward unknown because it keeps the check you installed TypeScript to get.
Yes to both. Vite understands TypeScript natively: scaffold the vanilla-ts or react-ts template and a tsconfig.json is waiting for you, with moduleResolution set for the bundler. JSX belongs in .tsx files, props get an interface, and hooks infer or accept explicit type arguments. The series covers typed React in depth, from props, state and events to refs, context and fetch effects.
Enums still compile and work fine in TypeScript 5.x, but they are no longer the default recommendation. A union of string literals such as "light" | "dark" needs no runtime object, matches JSON directly and plays well with single-file transpilers like Vite's esbuild. Keep enum for existing codebases that already use it, avoid const enum, and prefer literal unions in new code.
Adopt it incrementally instead of rewriting. Install typescript, add a tsconfig.json with allowJs and noEmit so .js and .ts coexist while your bundler keeps building, then check individual files with a // @ts-check comment. Rename leaf modules to .ts one at a time, fix implicit any errors first, and gate every pull request with tsc --noEmit so untyped code cannot sneak back in.