TypeScript project structure, strict tsconfig, module resolution, path aliases, shared types, and monorepo patterns
90
84%
Does it follow best practices?
Impact
100%
1.09xAverage score across 5 eval scenarios
Passed
No known issues
Practical structure, tsconfig configuration, and build setup for TypeScript projects. Covers strict mode, module resolution, path aliases, project organization, and monorepo patterns.
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 undefinednoImplicitAny — no implicit any typesstrictFunctionTypes — correct function type variancestrictBindCallApply — correct bind/call/apply typesstrictPropertyInitialization — class properties must be initializednoImplicitThis — no implicit this typingalwaysStrict — emits "use strict" in every fileWhy 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"];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:
import { foo } from "./utils.js" (even though the source file is .ts)import type for type-only imports (see below)package.json must have "type": "module" for ESMFor 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"
}
}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.jsTypeScript can enforce this with "verbatimModuleSyntax": true in tsconfig (recommended for new projects), which requires explicit import type for all type-only imports.
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:
tsx for development, or configure tsconfig-paths / tsc-alias for production buildsresolve.alias in vite.config.ts to match tsconfig pathspaths — no extra config neededSeparate source code from configuration files. All application code lives in src/.
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.jsproject/
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"]
}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.
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
*.tsbuildinfoNever 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.
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 outputdev uses tsx watch (not ts-node) for fast development with automatic reloadingbuild and start are separate — build produces JS, start runs the JStypecheck must validate BOTH client and server tsconfigsBarrel 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 dependenciesRules:
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:
What does NOT go in shared:
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.
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 paramsstrict: true in tsconfig — never cherry-pick strict optionsnoUncheckedIndexedAccess: true in tsconfigNodeNext for Node.js ESM, bundler for frontendimport type used for type-only imports@/* mapped to src/* in tsconfig pathssrc/, config files at project rootdist/ and node_modules/ in .gitignoretypecheck, build, dev, start, lint, test scripts definedenum)evals
scenario-1
scenario-2
scenario-3
scenario-4
scenario-5
skills
typescript-project-structure
verifiers