TypeScript tsconfig Strict Mode, .d.ts Files and Migration
The tsconfig options that matter, what strict turns on, .d.ts files and @types, tsc --noEmit beside fast transpilers, migrating JavaScript and common TS errors.
- Course: JavaScript study plan
- Module: TypeScript preview
- Kind: Lesson
- Reading time: 12 min
- Runtime: Node 16
What does strict mode do in tsconfig?
Setting "strict": true in tsconfig.json turns on TypeScript's whole strict family: noImplicitAny, strictNullChecks — so null and undefined are no longer part of every type — strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables and more. New projects should always start with it; turning it off to silence errors removes most of TypeScript's value.
Lesson
Knowing the type system is half of using TypeScript; the other half is the toolchain around it — a tsconfig.json whose dozen important options decide how strict and how compatible your build is, declaration files that describe JavaScript to the compiler, a build step that has to fit next to bundlers and test runners, and a migration path for existing JavaScript. This lesson is the practical layer: the options that matter and why, .d.ts files and @types, type-only imports, tsc versus fast transpilers, the migration recipe, runtime validation as the type system's partner, and the compiler errors you will meet most, decoded.
tsconfig.json, the options that matter
{
"compilerOptions": {
"strict": true, // the whole strict family — non-negotiable for new code
"target": "ES2022", // the JS syntax to emit; match your runtime (Node 16 → ES2021/ES2022)
"module": "NodeNext", // how imports are emitted/resolved: NodeNext follows package.json "type"; ESNext for bundlers
"moduleResolution": "NodeNext", // (or "Bundler" for Vite/webpack projects)
"lib": ["ES2022", "DOM"], // built-in type libraries; drop DOM for Node-only code
"esModuleInterop": true, // sane default imports from CommonJS
"noUncheckedIndexedAccess": true, // arr[i] is T | undefined — catches the most common runtime crash
"exactOptionalPropertyTypes": true, // optional means absent, not "or undefined"
"noImplicitOverride": true, "noFallthroughCasesInSwitch": true,
"skipLibCheck": true, // do not re-check node_modules .d.ts — faster, and their errors are not yours
"outDir": "dist", "rootDir": "src", "declaration": true, "sourceMap": true,
"isolatedModules": true // each file transpilable alone — required by esbuild/swc; forbids const enum & re-exporting types without `type`
},
"include": ["src"]
}
strict enables noImplicitAny, strictNullChecks (the big one — null/undefined are not in every type), strictFunctionTypes, strictPropertyInitialization, useUnknownInCatchVariables and more. Turning strict off to make errors disappear removes most of TypeScript's value; turn on the extra checks above as the project matures. target decides which syntax is downlevelled (??, ?., class fields) — set it to what your Node/browsers support so output stays readable and fast. module/moduleResolution mismatches produce the Cannot use import statement/ERR_REQUIRE_ESM family at run time (module 9): for Node projects use NodeNext and let package.json "type" decide.
Declaration files and @types
A .d.ts file declares types with no implementation — how the compiler knows about fs, the DOM, and every JavaScript library:
// types/legacy-lib.d.ts
declare module "legacy-lib" {
export function parse(input: string, opts?: { strict?: boolean }): Record<string, unknown>;
}
declare global { interface Window { analytics?: { track(event: string): void } } } // augment a global
Libraries ship their own ("types" in package.json) or the community provides them in DefinitelyTyped (npm i -D @types/express). Missing types → Could not find a declaration file for module 'x' → install @types/x, or write a minimal declare module "x"; (everything any — a stopgap). tsc --declaration emits .d.ts for your own package so consumers get types without your source. declare means "this exists at run time; here is its type" — variables, functions, modules, globals.
Type-only imports and erasure
import type { User } from "./types"; // erased entirely — no runtime import
import { type Config, loadConfig } from "./config"; // mixed: Config erased, loadConfig kept
export type { User };
With isolatedModules/verbatimModuleSyntax, imports used only as types must say so, or the transpiler cannot know whether to keep the import (a runtime import of a types-only module would fail or run side effects). Habit: import type for anything that is only a type.
Building and running
tsc type-checks and emits, but it is slow on large projects. The common setup separates the two: a fast transpiler strips types for running and bundling — esbuild, swc, tsx/ts-node for scripts (this repository runs its scripts with tsx), Vite/Next/webpack loaders for apps, Bun/Deno natively — and tsc --noEmit runs the type check in the editor, in a pre-commit hook and in CI. Fast transpilers do not type-check; if CI skips tsc --noEmit, type errors ship. Node 22.6+ can strip types itself (--experimental-strip-types); still no checking.
Migrating JavaScript
- Add
tsconfig.jsonwithallowJs: true,checkJs: true,strict: false,noEmit: true— the checker runs over.jswith inference and JSDoc, and reports nothing you have not asked for. - Fix what
checkJsfinds; add JSDoc types at module boundaries. - Rename files to
.tsleaf-first (utilities before the code that imports them);anywhere needed with a// TODO(types)comment. - Turn on
strictflags one at a time (noImplicitAny, thenstrictNullChecks— the largest batch). - Delete the
anys. Track their count; it should only go down.
Big-bang rewrites fail; the incremental path works because TypeScript accepts a mixed codebase.
Types and runtime validation
Types vanish at run time, so anything from outside needs a check the compiler can rely on. A schema library defines the shape once and provides both:
import { z } from "zod";
const User = z.object({ id: z.string(), name: z.string().min(1), email: z.string().email().optional() });
type User = z.infer<typeof User>; // the static type, derived from the schema
const user = User.parse(await res.json()); // runtime validation → a typed value, or a ZodError with paths
Hand-written guards (lesson 4) scale to a few shapes; schemas scale to an API. Either way the rule is the same: validate at the boundary, then trust the types inside.
The errors you will see most
| Error | Means | Fix |
|---|---|---|
TS2322: Type 'X' is not assignable to type 'Y' | shapes differ; read the last line of the message for the exact property | fix the value or the type; do not as |
TS2339: Property 'x' does not exist on type 'Y' | typo, missing narrowing, or the type is a union without x on every member | narrow first; check spelling |
TS2345: Argument of type 'A' is not assignable to parameter of type 'B' | wrong argument shape | same as 2322 |
TS7006: Parameter 'x' implicitly has an 'any' type | un-annotated parameter under noImplicitAny | annotate it |
TS2531/TS18047/TS18048: Object is possibly 'null' / 'undefined' | strictNullChecks — the value may be absent | check, ?., ??, or handle the case; ! only when certain |
TS2307: Cannot find module 'x' or its corresponding type declarations | not installed, wrong path/extension, or missing @types | install; fix the path; add a declaration |
TS2564: Property 'x' has no initializer and is not definitely assigned | class field not set in the constructor | initialise, make optional, or ! when a framework assigns it |
TS2352: Conversion of type 'A' to type 'B' may be a mistake | as between unrelated types | fix the logic; as unknown as B only knowingly |
TS1259/TS1192: Module has no default export | CJS/ESM interop | esModuleInterop, or import * as x |
TS2589: Type instantiation is excessively deep | a recursive type exploded | simplify the type |
Read errors bottom-up: the last "Type 'X' is not assignable to type 'Y'" line names the actual mismatch.
Common mistakes
strict: falsein a new project;skipLibCheckoff (slow) ornoEmitmisunderstood.- Running only a transpiler (esbuild/tsx) with no
tsc --noEmitin CI — errors ship. importof a type withouttypeunder isolated modules;const enumin bundled projects.as Useron parsed JSON instead of a schema/guard.- Migrating by rewriting everything at once; leaving
anyuncounted. - Ignoring the last line of a long error message.
Interview angle
- "Which tsconfig options matter most?"
strict,target,module/moduleResolution,esModuleInterop,noUncheckedIndexedAccess,skipLibCheck,isolatedModules. - "What is a
.d.tsfile?" Type declarations without implementation — how the compiler knows JavaScript libraries and globals;@types/*from DefinitelyTyped. - "How do you run TypeScript in production?" Transpile with tsc/esbuild/swc to JavaScript; type-check separately with
tsc --noEmitin CI. - "How would you migrate a JavaScript codebase?"
allowJs+checkJs, JSDoc at boundaries, rename leaf-first, enable strict flags incrementally, driveanyto zero. - "How do types relate to runtime validation?" Types are erased; validate at boundaries with guards or a schema library and infer the type from the schema.
Key takeaways
strict: truealways; matchtargetto the runtime;NodeNextfor Node;noUncheckedIndexedAccess,skipLibCheck,isolatedModuleson..d.tsand@typesdescribe JavaScript to the compiler;declare module/declare globalfor gaps;import typefor type-only imports.- Transpile fast (esbuild/swc/tsx), type-check separately (
tsc --noEmit) in editor, hook and CI. - Migrate incrementally:
checkJs→ JSDoc → rename leaf-first → strict flags → zeroany. - Validate at boundaries with schemas whose types you infer; read compiler errors from the last line.
Common questions
What is a .d.ts file in TypeScript?
A declaration file: it describes types with no implementation, which is how the compiler knows about Node's fs, the DOM and every JavaScript library. Packages ship their own or get them from DefinitelyTyped as @types/* packages, and tsc --declaration emits them for your own package.
How do I fix "Could not find a declaration file for module"?
Install the community types with npm i -D @types/x when they exist. Otherwise write a declaration file — a full declare module "x" { … }, or the one-line stopgap declare module "x";, which types everything in that module as any.
Does esbuild type-check TypeScript?
No. esbuild, swc and tsx only strip the types so the code can run, which is why they are fast. Run tsc --noEmit separately — in the editor, a pre-commit hook and CI — or type errors ship to production unnoticed.
How do you migrate a JavaScript project to TypeScript?
Incrementally. Start with allowJs and checkJs so the checker runs over the existing .js files, add JSDoc types at module boundaries, rename files to .ts leaf-first, turn on the strict flags one at a time — strictNullChecks is the largest batch — and drive the count of any to zero.
What does import type do in TypeScript?
import type { User } from "./types" imports only a type, and the transpiler erases the whole statement, so no runtime import happens. Under isolatedModules or verbatimModuleSyntax type-only imports must be marked this way, because a per-file transpiler cannot tell a type from a value.
Exercises
A tsconfig doctor
Read a tsconfig.json and print findings, each on its own line as - <error|warn|recommend|info>: <message>, preceded by tsconfig: <n> finding(s) (and - looks good when none). Rules, in order: strict not true → error (strict is off - enable it (implies noImplicitAny, strictNullChecks, strictFunctionTypes, useUnknownInCatchVariables)); missing noUncheckedIndexedAccess, skipLibCheck, isolatedModules → one recommend each; no target → warn; a target of ES3–ES2019 → warn (target <t> is older than the runtime supports - Node 16 runs ES2021); module/moduleResolution where exactly one is NodeNext/Node16 → warn that they disagree; module: commonjs without esModuleInterop → warn; no include/files → warn; outDir without rootDir → recommend; noEmit with outDir → info.
Example: {"compilerOptions":{"strict":false,"target":"es5","module":"commonjs","moduleResolution":"nodenext","outDir":"dist"}} → 9 findings, beginning
tsconfig: 9 finding(s)
- error: strict is off - enable it (implies noImplicitAny, strictNullChecks, strictFunctionTypes, useUnknownInCatchVariables)
- recommend: noUncheckedIndexedAccess: arr[i] becomes T | undefined and catches the most common crashDecode the compiler's errors
Each line is a TypeScript diagnostic beginning with its code. Build a rule table and print <code>: <hint>: TS2322 (a '<X>' was given where a '<Y>' is expected - fix the value or the type, do not cast), TS2345 (the argument is a '<A>' but the parameter wants '<B>'), TS2339 (no '<p>' on '<T>' - a typo, or narrow the union first), TS7006 (annotate parameter '<x>' (noImplicitAny)), TS2531/TS18047/TS18048 ('<expr>' may be <null|undefined> - check it, use ?. / ??, or handle the case (strictNullChecks), with the value when no expression is named), TS2307 (install '<m>' (and its @types), or fix the path/extension, or add a declaration), TS2564 (initialise '<p>' in the constructor, make it optional, or use ! when a framework assigns it), TS2352 ('<A>' and '<B>' do not overlap - the 'as' is hiding a logic error), TS1259 (enable esModuleInterop or use import * as x from '<m>'); anything else → no rule - read the last 'is not assignable' line of the message for the real mismatch.
Example (excerpt):
TS2339: no 'nmae' on 'User' - a typo, or narrow the union first
TS18048: 'user.email' may be undefined - check it, use ?. / ??, or handle the case (strictNullChecks)
TS2589: no rule - read the last 'is not assignable' line of the message for the real mismatchIn this module: TypeScript preview
- What TypeScript adds — erased types, inference, and any versus unknown versus never
- Object types, interfaces, structural typing and discriminated unions
- Function types, generics, keyof and the utility types
- Narrowing, type guards, assertions — making the compiler trust a value
- TypeScript in practice — tsconfig, declaration files, builds, migration and the errors you will see (this lesson)
- Checkpoint — TypeScript preview
← Narrowing, type guards, assertions — making the compiler trust a value · Checkpoint — TypeScript preview →