Skip to main content

Concern

Static typing across the project: backend, frontends, and shared (types and interfaces used consistently).

Technology

TypeScript

Documentation

Integration

Backend, frontends, and the shared package all use TypeScript. Shared package types are consumed by backend and frontends. Align with serialization (class-transformer DTOs) and validation (zod schemas) where shapes are shared across boundaries.

Version

v5

Implementation

The shared package uses a single shared/tsconfig.json that compiles to ESM with declaration files (outDir dist/), so both Node (backend) and the bundler (frontends) can consume it. The backend and each frontend get their own tsconfig from the Nest and shadcn scaffolds (nest new, shadcn create with Vite). Shared is built before any consumer runs; there is no TypeScript path mapping—backend and frontends resolve shared and frontends-shared via node_modules and the packages' exports in package.json.

TypeScript across the stack

Use the same TypeScript major version (v5) in backend, all frontends, and shared so type-checking is consistent.

Shared package

The shared package is a workspace package that compiles to ESM and declaration files so both backend (Node) and frontends (Vite/bundler) can use it.

  • shared/tsconfig.json: Use target: "ES2022", module: "ESNext", moduleResolution: "bundler", declaration: true, declarationMap: true, outDir: "./dist", rootDir: "./src". Do not include lib: ["DOM"] or JSX in shared; keep the package runtime-agnostic. Emit both JS and .d.ts (no noEmit).
  • shared/package.json: Set "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", and an exports field with the root (.dist/index.js) and a subpath pattern (./*dist/*.js) so any built module is importable as shared/<name> without per-file entries. See monorepo for adding shared modules.
  • After adding or changing shared, run pnpm --filter shared run build so consumers see up-to-date output. Root pnpm build builds shared first by dependency order.

Backend

Backend uses its own tsconfig (e.g. moduleResolution: "nodenext"). No tsconfig changes are required to consume shared; backend resolves shared via node_modules; types come from shared's types / exports. With moduleResolution: "nodenext", relative imports in backend source must use the .js extension (the runtime extension of the emitted file); e.g. from './mailing.service.js' not from './mailing.service'. Package imports (e.g. shared, @nestjs/...) stay extensionless.

Frontends

Each frontend uses its own tsconfig (e.g. with Vite/bundler resolution). No tsconfig path mapping for shared; frontends resolve shared from node_modules (workspace link). Ensure the frontend's tsconfig does not exclude the shared package; with the default include (e.g. ["src"]) and resolution via node_modules, types are found from shared's emitted .d.ts.

Background

Concepts that underpin the TypeScript configuration choices:

  • Modules — A module is a file that imports or exports; TypeScript and runtimes use module systems to resolve import/export and load code. The chosen system affects both how the compiler emits code and how Node or the bundler resolves package paths.

  • ESM (ECMAScript modules) — The standard JavaScript module format: import/export syntax, static structure, and asynchronous loading. Emitted as import/export in .js when TypeScript module is ESNext/ES2020+. Node supports ESM when the package has "type": "module" or the file is .mjs; bundlers (e.g. Vite) consume ESM natively.

  • CommonJS (CJS) — The legacy Node module format: require() and module.exports. TypeScript can emit CJS when module is CommonJS; Node runs it without "type": "module". Many older packages still use CJS, so interop (e.g. esModuleInterop) matters when the backend mixes ESM and CJS.

  • Node — The backend runtime. It supports both ESM and CommonJS; resolution follows package.json main/exports and (with the right Node/tsconfig) respects exports and types. Using moduleResolution: "nodenext" aligns the compiler with Node's actual resolution and ESM/CJS rules.

  • Bundler — Frontend build tools (e.g. Vite) bundle sources and resolve imports at build time; they follow package.json exports and typically assume ESM. moduleResolution: "bundler" matches how they resolve paths and allows features like importing .ts or types from exports.

Configuration

TypeScript options that matter for backend, frontend, and shared to work together, in order of importance:

  1. module and moduleResolution — Define how imports are resolved and what format is emitted.

    • Shared: module: "ESNext", moduleResolution: "bundler" so emitted JS is ESM and both Node and the bundler can consume it.
    • Backend: module: "nodenext", moduleResolution: "nodenext" so the Nest app runs as Node ESM and correctly resolves shared via its package.json exports.
    • Frontend: module: "ESNext", moduleResolution: "bundler" so Vite can bundle and resolve shared from node_modules.
  2. target — Controls the JavaScript language level of emitted code (e.g. ES2022); must be supported by both Node and the browser.

    • Shared: use "ES2022" (or same as the strictest consumer) so emitted code runs everywhere; keep at or below the lowest runtime target.
    • Backend: e.g. "ES2023".
    • Frontend: e.g. "ES2022" or higher.
  3. declaration and noEmit — Control whether the compiler emits .d.ts files and whether it emits any JS at all; shared must emit both for consumers to use it.

    • Shared: declaration: true and do not set noEmit so it emits both .js and .d.ts to outDir; consumers rely on these.
    • Backend: typically emits (Nest builds to dist).
    • Frontend: often noEmit: true because Vite handles emit; types are still resolved from shared's .d.ts.
  4. outDir and rootDir — Define where source lives and where compiled output is written; shared must align these with package.json main/types/exports.

    • Shared: rootDir: "./src", outDir: "./dist" so the package entry is dist/index.js and matches main/types/exports in package.json.
    • Backend: each has its own; no need to reference shared's dirs.
    • Frontend: each has its own; no need to reference shared's dirs.
  5. lib — Declares which built-in APIs (e.g. DOM, ES2022) the type checker assumes exist; shared must avoid DOM so it stays runtime-agnostic.

    • Shared: ["ES2022"] only; do not add "DOM" or browser-only libs so the package stays runtime-agnostic.
    • Backend: omit or use Node types via @types/node.
    • Frontend: include "DOM", "DOM.Iterable" for browser APIs.
  6. strict (and related flags) — Enable stricter type checking; shared should be at least strict so types are reliable for both consumers.

    • Shared: use at least strict: true so types are reliable for both consumers; avoid relaxing.
    • Backend: can be stricter.
    • Frontend: can be stricter.
  7. skipLibCheck — Skip type checking of declaration files in node_modules; use true everywhere to keep build times down.

    • Shared: true to avoid type-checking node_modules and keep build times down.
    • Backend: true.
    • Frontend: true.
  8. resolvePackageJsonExports — Whether the compiler respects package.json exports when resolving package paths; backend needs this for correct resolution of shared.

    • Shared: follows bundler resolution.
    • Backend: set true with moduleResolution: "nodenext" so Node and the compiler respect shared's exports in package.json.
    • Frontend: follows bundler resolution.
  9. esModuleInterop / allowSyntheticDefaultImports — Relax how default imports work with CommonJS modules; backend often needs these for Nest and CJS interop.

    • Shared: optional; ESM-only code may not need them.
    • Backend: often true for Nest and CommonJS interop.
    • Frontend: optional; ESM-only code may not need them.
  10. experimentalDecorators / emitDecoratorMetadata — Enable decorator syntax and metadata emission; required for NestJS and class-transformer-style DTOs when used.

    • Shared: only if you put decorators (e.g. class-transformer) in shared; otherwise omit.
    • Backend: required for NestJS decorators.
    • Frontend: only if using decorators there.

Hot reload (development)

For shared to trigger reloads in backend and frontends during pnpm dev: shared runs tsc --watch (build:watch); backend uses nodemon to watch shared/dist and restarts Nest when it changes; Vite watches the resolved shared output and triggers HMR. See monorepo for the full dev script and setup.