cryeffect.net

TypeScript Projektaufbau – Grundstruktur mit esbuild und ESM-Modulen

TypeScript Projektaufbau – kompakt, modern, ohne Framework

Wenn du ein schlankes TypeScript-Projekt starten willst (CLI-Tool, kleiner Service, Skript), brauchst du meist keine riesige Toolchain. Mit TypeScript + esbuild (Build) in einem ESM-Projekt bekommst du:

  • schnelle Builds
  • saubere Ordnerstruktur
  • modernes import/export
  • einfache npm run build / npm run dev

Hinweis

Dieser Artikel geht von Node.js als Runtime aus und nutzt ESM (ECMAScript Modules).
ESM ist dabei keine „Toolchain-Komponente“, sondern die Art, wie dein JavaScript-Projekt (Imports/Exports) aufgebaut ist.
Das ist heute der „moderne“ Standard, hat aber ein paar Regeln (Dateiendungen/type: module) – dazu gleich mehr.

Ziel: eine sinnvolle Grundstruktur

Eine gute Standard-Struktur für kleine bis mittlere Projekte (mit klarer Trennung in frontend, common und backend):

1
2
3
4
5
6
7
8
9
10
11
12
13
my-ts-app/
src/
backend/
index.ts
frontend/
index.ts
common/
logger.ts
dist/
backend/
frontend/
package.json
tsconfig.json
  • src/: dein Quellcode (TypeScript)
    • src/backend/: Node-Code (z. B. CLI/Service)
    • src/frontend/: Browser-Code (UI)
    • src/common/: gemeinsam genutzte Module
  • dist/: Build-Ausgabe (JavaScript)
  • tsconfig.json: TypeScript-Compiler-Settings (für Typencheck & Editor)
  • esbuild: übernimmt den Build (schnell) und kann bundlen oder „nur“ transpilen

Voraussetzungen

  • Node.js (aktuelles LTS empfohlen)
  • npm (oder pnpm/yarn – die Beispiele nutzen npm)

Schritt 1: Projekt initialisieren

1
2
3
4
mkdir my-ts-app
cd my-ts-app
npm init -y
npm i -D typescript esbuild @types/node

Optional, aber praktisch:

1
npx tsc --init

(Wir passen tsconfig.json gleich an.)

Schritt 2: `package.json` für ESM + Scripts

Wichtig für ESM in Node: setze "type": "module".

Beispiel package.json (relevanter Teil):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"name": "my-ts-app",
"private": true,
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
"start": "node dist/backend/index.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"esbuild": "^0.24.0",
"typescript": "^5.0.0"
}
}

Warum diese Aufteilung?

  • typecheck: TypeScript macht nur Typprüfung (kein Output)
  • build: esbuild baut nach dist/
  • dev: esbuild im Watch-Modus
  • start: startet das gebaute JavaScript

Schritt 3: `tsconfig.json` (Editor + Typencheck)

Für esbuild ist tsconfig.json primär für Typprüfung und Editor-Features relevant.

Wichtig: esbuild macht keine Type-Checks. Es transpiliert TypeScript zu JavaScript und kann dabei auch Code ausgeben, den der TypeScript-Compiler (tsc) aus Typ-Gründen ablehnen würde.
Darum ist npm run typecheck (mit tsc --noEmit) ein guter Standard.

Eine solide Basis:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],

"module": "NodeNext",

"moduleResolution": "NodeNext",
"verbatimModuleSyntax": true,

"strict": true,
"skipLibCheck": true,

"rootDir": "src",
"outDir": "dist",

"types": ["node"]
},
"include": ["src/**/*.ts"]
}

Hinweise:

  • Wenn du wirklich nur Backend (Node) baust, kannst du "DOM" in lib wieder entfernen.
  • moduleResolution: "Bundler" brauchst du fürs Bauen mit esbuild in der Regel nicht.
  • Mit NodeNext orientierst du dich stärker an den Regeln der Node-ESM-Runtime.

Schritt 4: esbuild Build (Config-Datei)

Lege eine Datei esbuild.config.mjs im Projektroot an:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import esbuild from 'esbuild';

const args = new Set(process.argv.slice(2));
const isWatch = args.has('--watch');

// Standard: beide bauen. Optional kannst du eins einschränken:
// - nur Backend: node esbuild.config.mjs --backend
// - nur Frontend: node esbuild.config.mjs --frontend
const buildBackend = args.has('--backend') || (!args.has('--frontend') && !args.has('--backend'));
const buildFrontend = args.has('--frontend') || (!args.has('--frontend') && !args.has('--backend'));

/** @type {import('esbuild').BuildOptions} */
const backendOptions = {
entryPoints: ['src/backend/index.ts'],
outdir: 'dist/backend',
platform: 'node',
format: 'esm',
target: 'node20',
sourcemap: true,
bundle: false,
logLevel: 'info'
};

/** @type {import('esbuild').BuildOptions} */
const frontendOptions = {
entryPoints: ['src/frontend/index.ts'],
outdir: 'dist/frontend',
platform: 'browser',
format: 'esm',
target: 'es2022',
sourcemap: true,
bundle: true,
logLevel: 'info'
};

const builds = [];
if (buildBackend) builds.push(backendOptions);
if (buildFrontend) builds.push(frontendOptions);

if (isWatch) {
const contexts = await Promise.all(builds.map((o) => esbuild.context(o)));
await Promise.all(contexts.map((c) => c.watch()));
console.log('Watching…');
} else {
await Promise.all(builds.map((o) => esbuild.build(o)));
}

Was hier passiert:

  • platform: 'node': esbuild optimiert für Node
  • format: 'esm': Ausgabe bleibt ESM (import/export)
  • bundle: true: (hier fürs Frontend) alles wird zu einem Bundle zusammengefasst
  • sourcemap: true: Debugging in dist/ ist angenehm

Wenn du (z. B. im Frontend) nicht bundlen willst, setze bundle: false und achte stärker auf ESM-Imports/Dateiendungen.

Schritt 5: Beispielcode

Lege diese Dateien an:

src/common/logger.ts

1
2
3
export function logInfo(message: string): void {
console.log(`[info] ${message}`);
}

src/backend/index.ts

1
2
3
import { logInfo } from '../common/logger.js';

logInfo('Build läuft!');

src/frontend/index.ts

1
2
3
4
5
6
import { logInfo } from '../common/logger.js';

logInfo('Frontend läuft!');

const el = document.querySelector('#app');
if (el) el.textContent = 'Hello from frontend';

Wichtig

Siehst du das .js in ../common/logger.js?
Bei ESM in Node ist das häufig nötig, weil Node zur Laufzeit JavaScript-Dateien lädt.
TypeScript akzeptiert diese Schreibweise trotzdem, wenn die Settings passen (siehe moduleResolution).

Build & Run

1
2
3
npm run typecheck
npm run build
npm run start

Wenn du nur eins davon bauen willst:

1
2
npm run build -- --backend
npm run build -- --frontend

Für Entwicklung (Watch):

1
npm run dev

Typische Stolperfallen (ESM + TypeScript)

  • Fehlendes "type": "module": Dann interpretiert Node Ausgaben ggf. als CommonJS.
  • Import-Pfade ohne .js: Bei ESM in Node kann das knallen. Im Frontend (Bundle) fällt das oft weniger auf – im Backend ohne Bundle musst du es sauber machen.
  • require/__dirname: In ESM gibt’s require nicht direkt. Du nutzt stattdessen import.meta.url + fileURLToPath.

Minimal-Beispiel für __dirname in ESM:

1
2
const __filename = import.meta.filename;
const __dirname = import.meta.dirname;

Varianten: App vs. Library

  • Backend-App/CLI/Tool: oft bundle: false (näher an Node-Runtime, weniger „Magie“). Wenn du ein Single-File-Deployment willst, kannst du trotzdem bundlen.
  • Frontend: praktisch immer bundle: true.
  • Library: eher bundle: false, dafür sauberer Export-Plan (z. B. exports-Map in package.json) und ggf. zusätzlich CJS-Output.

Fazit

Mit dieser Struktur hast du ein modernes TypeScript-Projekt, das schnell baut, sauber typgecheckt wird und ESM nutzt – ohne Overkill.

Wenn du willst, ergänze ich dir als nächsten Schritt auch noch:

  • exports-Map für Library-Releases
  • Dual-Publish (ESM + CJS)
  • Minimal-Setup für Tests (z. B. Vitest)