Appearance
TypeScript Projects and Library Boundaries
A TypeScript project succeeds when three models agree: the imports written in source, the files understood by the checker, and the files loaded by the runtime. Many frustrating TypeScript problems come from configuring only one of those layers.
Choose settings from the execution environment
For a modern Node ESM application, a starting configuration might be:
json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}NodeNext models Node's ESM and CommonJS behavior, including package metadata and file extensions. A bundler-based browser application commonly uses module: "ESNext", moduleResolution: "Bundler", and noEmit: true, because the bundler resolves and transforms source.
Do not copy a tsconfig solely because it is labeled “strict.” The correct module and library settings depend on deployment. The compiler option reference and framework template are better starting points than a maximal anonymous gist.
With Node ESM, source commonly imports the eventual runtime filename:
ts
import { buildReport } from "./build-report.js";TypeScript resolves that specifier to build-report.ts during checking and preserves .js for Node. Writing .ts can produce imports that emitted JavaScript cannot load unless a specialized runtime rewrites or supports them.
Compile and check are separable jobs
tsc can both check and emit JavaScript. A project may instead use a fast transformer such as Vite, esbuild, SWC, Bun, or Node's type stripping. Most such transformations erase types without performing full type checking.
Make both responsibilities explicit:
json
{
"scripts": {
"check": "tsc --noEmit",
"build": "vite build"
}
}An editor diagnostic is excellent feedback but not a reproducible build gate. A clean check in automation ensures the whole configured program is checked with committed settings.
Source maps connect runtime stack traces back to TypeScript source. Enable them for emitted projects and verify that the deployment retains or uploads them appropriately without unintentionally exposing source.
Declaration files describe JavaScript surfaces
Type declarations (.d.ts) contain types without implementations. Packages often publish them alongside JavaScript. The types field or exports map tells tools where the public declarations live.
A small library source might export:
ts
export type Report = {
count: number;
total: number;
};
export function buildReport(values: readonly number[]): Report {
return {
count: values.length,
total: values.reduce((sum, value) => sum + value, 0),
};
}With declaration: true, TypeScript can emit a corresponding .d.ts. Consumers compile against that public contract, so exported inferred types should be stable and intelligible. Do not expose accidental internal types merely because inference can name them.
Package boundaries also need runtime design: which subpaths are public, whether the library provides ESM, CommonJS, or both, and which JavaScript versions it targets. Publishing dual module formats is possible but adds resolution and testing complexity. Support only formats your consumers need.
JavaScript libraries can be adopted gradually
TypeScript understands declaration packages, often installed as @types/name, when a JavaScript package does not ship its own types. A declaration can be wrong or version-mismatched; runtime documentation remains authoritative.
For local JavaScript migration, allowJs includes .js files and checkJs checks them. JSDoc provides types without renaming the file:
js
/**
* @param {number[]} values
* @returns {number}
*/
export function total(values) {
return values.reduce((sum, value) => sum + value, 0);
}This can be a durable choice for JavaScript libraries as well as a migration step. TypeScript is a checker, not a requirement that all source use .ts.
Keep unsafe edges narrow
Third-party SDKs, environment variables, JSON, and dynamic plugins are boundary data. Convert them into trusted application types at one location:
ts
type Environment = {
apiBaseUrl: URL;
retryCount: number;
};
function readEnvironment(source: NodeJS.ProcessEnv): Environment {
const rawUrl = source.API_BASE_URL;
if (!rawUrl) throw new Error("API_BASE_URL is required");
const retryCount = Number(source.RETRY_COUNT ?? "3");
if (!Number.isInteger(retryCount) || retryCount < 0) {
throw new Error("RETRY_COUNT must be a non-negative integer");
}
return { apiBaseUrl: new URL(rawUrl), retryCount };
}Inner code now receives a URL and number rather than repeatedly interpreting strings.
Avoid broad ambient declarations that claim an untyped module is safe:
ts
declare module "mystery-package";That effectively makes its exports any. A narrow adapter with a small local declaration or runtime validation contains uncertainty more honestly.
Organize types with the code they describe
A global types.ts tends to become an unrelated warehouse. Place Transaction, its parser, and its domain operations near one another; export the public types callers actually need. Type-only dependency cycles are less dangerous at runtime, but they can still reveal confused ownership.
Use project references and multiple tsconfig files only when independently built packages or materially different environments justify them. A browser client and Node server need different global libraries; a small application often does not need a monorepo architecture.
Useful project scripts remain few and explicit:
json
{
"scripts": {
"check": "tsc --noEmit",
"test": "vitest run",
"lint": "eslint .",
"build": "vite build"
}
}The important boundary is not TypeScript versus JavaScript. It is trusted internal assumptions versus values that can violate them, and source-level expectations versus what the selected runtime will actually execute.