CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl-labs/typescript-project-structure

TypeScript project structure, strict tsconfig, module resolution, path aliases, shared types, and monorepo patterns

90

1.09x
Quality

84%

Does it follow best practices?

Impact

100%

1.09x

Average score across 5 eval scenarios

SecuritybySnyk

Passed

No known issues

Overview
Quality
Evals
Security
Files

SKILL.mdskills/typescript-project-structure/

name:
typescript-project-structure
description:
TypeScript project structure, tsconfig configuration, and build setup. Use when starting any TypeScript project, setting up tsconfig.json, configuring path aliases, separating src from config, setting up ESM modules, creating build/dev scripts, or when seeing "cannot find module" errors. Triggers on: new TypeScript project, tsconfig setup, module resolution issues, path alias configuration, project scaffolding, "strict mode", barrel exports, type-only imports, monorepo setup, or build configuration.
keywords:
typescript project structure, tsconfig, strict mode, path aliases, module resolution, NodeNext, ESM, barrel exports, type-only imports, declaration files, monorepo, project references, eslint typescript, build scripts, src directory, gitignore, noUncheckedIndexedAccess
license:
MIT

TypeScript Project Structure

Practical structure, tsconfig configuration, and build setup for TypeScript projects. Covers strict mode, module resolution, path aliases, project organization, and monorepo patterns.


Critical Pattern: Strict tsconfig.json

Every TypeScript project must enable strict mode and key additional checks. A loose tsconfig negates most of TypeScript's value.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noEmit": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "rootDir": "src",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

What strict: true enables (never cherry-pick — enable all of these together):

  • strictNullChecks — forces handling of null and undefined
  • noImplicitAny — no implicit any types
  • strictFunctionTypes — correct function type variance
  • strictBindCallApply — correct bind/call/apply types
  • strictPropertyInitialization — class properties must be initialized
  • noImplicitThis — no implicit this typing
  • alwaysStrict — emits "use strict" in every file

Why noUncheckedIndexedAccess: true: Without this, Record<string, Foo> and array index access returns Foo instead of Foo | undefined, hiding potential runtime errors. This catches bugs like:

const map: Record<string, number> = {};
// Without noUncheckedIndexedAccess: `value` is `number` (WRONG — it's undefined)
// With noUncheckedIndexedAccess: `value` is `number | undefined` (CORRECT)
const value = map["missing"];

Critical Pattern: Module Resolution — Use NodeNext for ESM

For Node.js projects using ES modules, use "module": "NodeNext" and "moduleResolution": "NodeNext". This is the correct setting for modern Node.js with ESM.

// package.json
{
  "type": "module"
}
// tsconfig.json
{
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext"
  }
}

Key rules with NodeNext:

  • Relative imports MUST include file extensions: import { foo } from "./utils.js" (even though the source file is .ts)
  • Use import type for type-only imports (see below)
  • package.json must have "type": "module" for ESM

For frontend projects using a bundler (Vite, webpack, Next.js), use "moduleResolution": "bundler" instead — the bundler handles module resolution:

// tsconfig.json for Vite/React/Next.js projects
{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx"
  }
}

Critical Pattern: Type-Only Imports

Always use import type when importing something used only as a type. This prevents the import from appearing in compiled output and makes dependencies clearer.

// GOOD — type-only import
import type { User } from "./types.js";
import type { Request, Response } from "express";

// GOOD — mixed import (values and types from same module)
import { UserSchema, type User } from "./user.js";

// BAD — importing type as regular import
import { User } from "./types.js";  // This creates a runtime dependency on types.js

TypeScript can enforce this with "verbatimModuleSyntax": true in tsconfig (recommended for new projects), which requires explicit import type for all type-only imports.


Critical Pattern: Path Aliases with @ Prefix

Configure path aliases to avoid deep relative imports. The @/ prefix mapping to src/ is the most common convention.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

This turns:

// BAD — fragile deep relative imports
import { User } from "../../../types/user.js";
import { db } from "../../../lib/database.js";

// GOOD — clear absolute-style imports
import type { User } from "@/types/user.js";
import { db } from "@/lib/database.js";

Important: Path aliases need runtime support too:

  • Node.js: Use tsx for development, or configure tsconfig-paths / tsc-alias for production builds
  • Vite: Configure resolve.alias in vite.config.ts to match tsconfig paths
  • Next.js: Automatically reads tsconfig paths — no extra config needed

Critical Pattern: Project Directory Structure

Separate source code from configuration files. All application code lives in src/.

Single-package project (API, CLI, or library)

project/
  src/
    index.ts              # Entry point
    routes/               # HTTP handlers (if API)
    services/             # Business logic
    types/                # Shared type definitions
      index.ts            # Re-exports (barrel file)
    lib/                  # Utilities, helpers
    config/               # App configuration
  tests/                  # Test files (mirror src/ structure)
    routes/
    services/
  dist/                   # Build output (gitignored)
  package.json
  tsconfig.json           # Main config (for src/)
  tsconfig.test.json      # Test config (extends main, adds tests/)
  .gitignore
  .eslintrc.cjs           # or eslint.config.js

Full-stack project (shared types between client and server)

project/
  shared/
    types.ts              # Types used by both client and server
  server/
    index.ts
    routes.ts
  src/                    # Frontend (Vite/React)
    App.tsx
    main.tsx
    api.ts                # Imports from @shared/*
  package.json
  tsconfig.json           # Base config for frontend
  tsconfig.server.json    # Server-specific overrides
  vite.config.ts
// tsconfig.json — base config for frontend
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "paths": {
      "@shared/*": ["./shared/*"]
    }
  },
  "include": ["src", "shared"]
}
// tsconfig.server.json — server overrides
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist/server",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "paths": {
      "@shared/*": ["./shared/*"]
    }
  },
  "include": ["server", "shared"]
}

Critical Pattern: Separate tsconfig for Tests

Tests need different compiler settings (looser types for mocks, different module targets). Create a tsconfig.test.json that extends the base config.

// tsconfig.test.json
{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "rootDir": ".",
    "types": ["vitest/globals"]
  },
  "include": ["src", "tests"]
}

This way tsc --noEmit checks source code strictly, while tests get the extra type definitions they need.


Critical Pattern: Proper .gitignore

Always ignore build output and dependency directories:

# Dependencies
node_modules/

# Build output
dist/
build/

# Environment
.env
.env.local
.env.*.local

# IDE
.vscode/settings.json
.idea/

# OS
.DS_Store
Thumbs.db

# TypeScript
*.tsbuildinfo

Never commit dist/ or node_modules/. The dist/ directory is generated from source and should be rebuilt on deploy. Committing it causes merge conflicts and stale output.


Critical Pattern: Build and Dev Scripts

Every TypeScript project needs these scripts at minimum:

{
  "scripts": {
    "build": "tsc",
    "dev": "tsx watch src/index.ts",
    "start": "node dist/index.js",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src/",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

For full-stack projects:

{
  "scripts": {
    "dev": "concurrently \"npm run dev:server\" \"npm run dev:client\"",
    "dev:server": "tsx watch server/index.ts",
    "dev:client": "vite",
    "build": "tsc -p tsconfig.server.json && vite build",
    "start": "node dist/server/index.js",
    "typecheck": "tsc --noEmit && tsc -p tsconfig.server.json --noEmit",
    "lint": "eslint src/ server/ shared/",
    "test": "vitest run"
  }
}

Key points:

  • typecheck runs tsc --noEmit — it validates types without producing output
  • dev uses tsx watch (not ts-node) for fast development with automatic reloading
  • build and start are separate — build produces JS, start runs the JS
  • For full-stack, typecheck must validate BOTH client and server tsconfigs

Barrel Exports and Their Bundle Impact

Barrel files (index.ts that re-exports from other files) are convenient but can cause bundle bloat. Use them selectively.

// src/types/index.ts — Barrel file for types (GOOD — types are erased at compile time)
export type { User } from "./user.js";
export type { Order, OrderStatus } from "./order.js";
export type { ApiResponse, ApiError } from "./api.js";
// src/utils/index.ts — Barrel file for utilities (CAUTION — can pull in entire module tree)
export { formatDate } from "./date.js";
export { slugify } from "./string.js";
export { logger } from "./logger.js";  // This pulls in logger + all its dependencies

Rules:

  • Barrel files for types are always safe (types are erased)
  • Barrel files for runtime code are fine in Node.js (no tree-shaking concern)
  • Barrel files for frontend code can cause bundle bloat — importing one function pulls in everything the barrel re-exports unless the bundler tree-shakes effectively
  • Never create deeply nested barrel chains (barrel importing from barrel importing from barrel)

Shared Types Between Client and Server

When both frontend and backend use TypeScript, define shared types once and import them in both.

// shared/types.ts

// Use union types for string enums (not TypeScript enum — better for serialization)
export type OrderStatus = "received" | "preparing" | "ready" | "picked_up" | "cancelled";

// Entity types
export interface Order {
  id: string;
  customerName: string;
  items: OrderItem[];
  status: OrderStatus;
  totalCents: number;
  createdAt: string;  // ISO 8601 — use string, not Date, for JSON serialization
}

export interface OrderItem {
  menuItemId: string;
  name: string;
  quantity: number;
  priceCents: number;
}

// API request/response shapes
export interface CreateOrderRequest {
  customerName: string;
  items: { menuItemId: string; quantity: number }[];
}

export interface ApiResponse<T> {
  data: T;
}

export interface ApiError {
  error: { code: string; message: string };
}

What goes in shared:

  • Entity types used on both sides (User, Order, Product)
  • API request/response shapes
  • String union types (status values, categories)
  • Validation constants (max lengths, allowed values)

What does NOT go in shared:

  • Server-only types (database row types, middleware types, ORM models)
  • Client-only types (component props, UI state, form state)
  • Implementation types (internal function signatures)

Monorepo Patterns (Project References)

For larger projects, use TypeScript project references with workspaces.

project/
  packages/
    shared/
      src/
        types.ts
      package.json          # "name": "@app/shared"
      tsconfig.json
    server/
      src/
        index.ts
      package.json          # depends on "@app/shared"
      tsconfig.json         # references ../shared
    client/
      src/
        App.tsx
      package.json          # depends on "@app/shared"
      tsconfig.json         # references ../shared
  package.json              # workspaces: ["packages/*"]
  tsconfig.json             # references all packages
// Root tsconfig.json — project references
{
  "references": [
    { "path": "packages/shared" },
    { "path": "packages/server" },
    { "path": "packages/client" }
  ],
  "files": []
}
// packages/server/tsconfig.json
{
  "compilerOptions": {
    "composite": true,
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true
  },
  "references": [
    { "path": "../shared" }
  ],
  "include": ["src"]
}

Build with tsc --build to compile all packages in dependency order.


ESLint with typescript-eslint

Use typescript-eslint for TypeScript-aware linting:

// package.json devDependencies
{
  "eslint": "^9.0.0",
  "@typescript-eslint/parser": "^8.0.0",
  "@typescript-eslint/eslint-plugin": "^8.0.0",
  "typescript-eslint": "^8.0.0"
}
// eslint.config.js (flat config — ESLint 9+)
import tseslint from "typescript-eslint";

export default tseslint.config(
  ...tseslint.configs.recommended,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
    rules: {
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
      "@typescript-eslint/consistent-type-imports": "error",
      "@typescript-eslint/no-floating-promises": "error",
    },
  },
  {
    ignores: ["dist/", "node_modules/"],
  }
);

Key rules to enable:

  • @typescript-eslint/consistent-type-imports — enforces import type for type-only imports
  • @typescript-eslint/no-floating-promises — catches unhandled promises
  • @typescript-eslint/no-unused-vars — with argsIgnorePattern: "^_" for intentionally unused params

Quick Checklist

  • strict: true in tsconfig — never cherry-pick strict options
  • noUncheckedIndexedAccess: true in tsconfig
  • Module resolution matches runtime: NodeNext for Node.js ESM, bundler for frontend
  • import type used for type-only imports
  • Path alias @/* mapped to src/* in tsconfig paths
  • Source code in src/, config files at project root
  • dist/ and node_modules/ in .gitignore
  • Separate tsconfig for tests extending base config
  • typecheck, build, dev, start, lint, test scripts defined
  • Shared types in one location when both client and server use TypeScript
  • Union types for string enums (not TypeScript enum)
  • ESLint configured with typescript-eslint

Verifiers

  • strict-tsconfig — Enable strict mode and noUncheckedIndexedAccess in tsconfig.json
  • module-resolution — Use correct module resolution for the runtime (NodeNext for ESM, bundler for frontend)
  • type-only-imports — Use import type for type-only imports
  • project-organization — Source code in src/, proper .gitignore, build/dev scripts
  • shared-types — Put shared types in a common location imported by both client and server
  • path-aliases — Configure path aliases to avoid deep relative imports

skills

typescript-project-structure

tile.json