This is an opinionated view for how Node.js TypeScript projects should be configured and maintained.
Prerequisites
Before proceeding:
- Ensure nvm (Node Version Manager) and Node.js are installed using the
jeff-skill-install-nodejsskill. - Ensure prettier is installed using the
jeff-skill-install-prettierskill. - Use WebSearch to verify current versions:
- "TypeScript latest version [current-year]"
- "Vitest latest version [current-year]"
- "ESLint TypeScript latest version [current-year]"
- Update all version numbers in examples below with verified versions
- DO NOT skip this step. DO NOT guess at version numbers.
Goals
- Use latest Node.js LTS runtime
- Enforce strict TypeScript type checking
- Use ESLint for linting with TypeScript support
- Use Prettier for code formatting (via
jeff-skill-install-prettierskill) - Use Vitest for testing with 80%+ code coverage
- Keep dependencies minimal and deliberate
- Make test/lint/build repeatable and auditable
Required Layout
Project Structure
project-root/
├── src/
│ ├── index.ts
│ └── lib/
│ └── example.ts
├── tests/
│ └── example.test.ts
├── dist/ # Build output (gitignored)
├── tsconfig.json
├── tsconfig.build.json # Separate config for building
├── package.json
├── .eslintrc.json
├── .gitignore
├── vitest.config.ts
└── README.md
Configuration Files
package.json
{
"name": "project-name",
"version": "1.0.0",
"type": "module",
"description": "Project description",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"lint": "eslint src tests --ext .ts",
"lint:fix": "eslint src tests --ext .ts --fix",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist node_modules",
"prepublishOnly": "npm run build"
},
"dependencies": {},
"devDependencies": {
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.0",
"tsx": "^4.0.0",
"typescript": "^5.9.0",
"vitest": "^4.0.18"
}
}
tsconfig.json
Main TypeScript configuration with strict settings:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}
tsconfig.build.json
Separate configuration for production builds:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declarationMap": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests", "**/*.test.ts"]
}
.eslintrc.json
{
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module",
"project": "./tsconfig.json"
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-requiring-type-checking"
],
"plugins": ["@typescript-eslint"],
"root": true,
"env": {
"node": true,
"es2022": true
},
"rules": {
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-floating-promises": "error"
}
}
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: ['node_modules/', 'dist/', 'tests/', '**/*.test.ts', '**/*.config.ts'],
thresholds: {
lines: 80,
functions: 80,
branches: 80,
statements: 80
}
}
}
});
.gitignore
# Dependencies
node_modules/
# Build output
dist/
build/
# Testing
coverage/
.nyc_output/
# Environment
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# TypeScript
*.tsbuildinfo
Project Setup Commands
Initialize Project
# Create project directory
mkdir project-name && cd project-name
# Initialize npm project
npm init -y
# Install TypeScript and build tools
npm install -D typescript @types/node tsx
# Install testing framework
npm install -D vitest @vitest/coverage-v8
# Install linting
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# Create directory structure
mkdir -p src/lib tests
# Create initial files
touch src/index.ts tests/example.test.ts
Common Commands
# Development with hot reload
npm run dev
# Build for production
npm run build
# Run tests
npm test
# Run tests with coverage
npm run test:coverage
# Lint code
npm run lint
# Format code
npm run format
# Type check
npm run typecheck
# Run all checks (lint + format + typecheck + test)
npm run lint && npm run format:check && npm run typecheck && npm run test:run
Testing Requirements
- Use Vitest for all tests
- Tests must live in
tests/directory - Minimum 80% code coverage required
- Coverage reports generated in
coverage/directory - Tests must be runnable via
npm test
Example Test
// tests/example.test.ts
import { describe, it, expect } from 'vitest';
import { add } from '../src/lib/example';
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('should add negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
it('should handle zero', () => {
expect(add(0, 5)).toBe(5);
});
});
Example Source
// src/lib/example.ts
export function add(a: number, b: number): number {
return a + b;
}
Code Quality Standards
- All code must pass
npm run lintwith no errors - All code must be formatted with Prettier
- All code must pass
npm run typecheckwith no errors - Use strict TypeScript settings
- No
anytypes (useunknownwhen necessary) - Explicit return types for functions
- Handle promises properly (no floating promises)
GitHub Actions
Create .github/workflows/ci.yml:
name: jeff-skill-typescript-project
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Lint
run: npm run lint
- name: Format check
run: npm run format:check
- name: Run tests with coverage
run: npm run test:coverage
- name: Build
run: npm run build
Best Practices
- Keep dependencies minimal
- Use
type: "module"in package.json for ESM - Pin dependency versions for reproducibility
- Use
tsxfor development with hot reload - Build with
tscfor production - Never commit
node_modules/ordist/ - Use
.nvmrcto specify Node.js version - Export types from your library's main entry point
For Library Projects
If building a library (not an application):
- Set
"main"and"types"in package.json - Build declaration files with
"declaration": true - Consider dual ESM/CJS exports if needed
- Test your library as a consumer would use it
For Backend/API Projects
If building a backend API:
- Use environment variables for configuration
- Add request validation
- Use structured logging (pino, winston)
AWS Lambda Development
When building AWS Lambda functions with TypeScript, use AWS Lambda Powertools for TypeScript:
import { Logger } from '@aws-lambda-powertools/logger';
import { Tracer } from '@aws-lambda-powertools/tracer';
import type { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
const logger = new Logger({ serviceName: 'userService' });
const tracer = new Tracer({ serviceName: 'userService' });
export const handler = async (event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult> => {
logger.addContext(context);
logger.info('Processing request', { requestId: context.requestId });
try {
// Your business logic here
const result = { message: 'Success' };
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
logger.error('Error processing request', { error });
return {
statusCode: 500,
body: JSON.stringify({ error: 'Internal server error' })
};
}
};
Key Powertools Features
- Logger: Structured JSON logging with context injection
- Tracer: X-Ray tracing for debugging and performance (normally disabled, enable when explicitly needed)
- Event Handler: Type-safe routing for API Gateway, ALB, Lambda Function URLs, AppSync - handles multiple routes
- Parameters: Easy access to SSM Parameter Store and Secrets Manager
- Idempotency: Built-in idempotency handling
Lambda Best Practices for TypeScript
- Keep functions small and focused (single responsibility)
- Use environment variables for configuration
- Optimize for cold starts (minimize dependencies, avoid lazy loading, keep imports at top)
- Use typed event structures from
@types/aws-lambda - Return proper API Gateway response format
- Set appropriate memory and timeout values
- Use ARM64 (Graviton) architecture for better price/performance
- Use structured logging, not console.log
Handling Multiple Routes
Recommended: Manual Path Checking (Cold Start Optimized)
For 2-5 routes, use simple path checking - no extra dependencies, faster cold starts:
import { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from 'aws-lambda';
import { Logger } from '@aws-lambda-powertools/logger';
const logger = new Logger();
export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => {
const path = event.rawPath || event.requestContext.http.path;
const method = event.requestContext.http.method;
logger.info('Request received', { path, method });
// Parse body once
let body: unknown;
try {
body = event.body ? JSON.parse(event.body) : {};
} catch {
return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON' }) };
}
// Route handling
if (method === 'GET' && path === '/users') {
return handleGetUsers();
} else if (method === 'POST' && path === '/users') {
return handleCreateUser(body);
} else if (method === 'GET' && path.startsWith('/users/')) {
const id = path.split('/')[2];
return handleGetUser(id);
}
return { statusCode: 404, body: JSON.stringify({ error: 'Not found' }) };
};
async function handleGetUsers(): Promise<APIGatewayProxyResultV2> {
// Implementation
return { statusCode: 200, body: JSON.stringify({ users: [] }) };
}
async function handleCreateUser(body: unknown): Promise<APIGatewayProxyResultV2> {
// Implementation
return { statusCode: 201, body: JSON.stringify({ user: body }) };
}
async function handleGetUser(id: string): Promise<APIGatewayProxyResultV2> {
// Implementation
return { statusCode: 200, body: JSON.stringify({ user: { id } }) };
}
Last Resort: Event Handler (Only for 10+ routes)
If you have 10+ routes in a single Lambda (which usually means you're doing something wrong - consider splitting into focused functions), use Event Handler:
import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda';
import { Logger } from '@aws-lambda-powertools/logger';
import { ApiGatewayResolver } from '@aws-lambda-powertools/event-handler';
const logger = new Logger();
const app = new ApiGatewayResolver();
app.get('/users', async () => ({ users: [] }));
app.post('/users', async (event) => {
const body = JSON.parse(event.body || '{}');
return { user: { id: '123', ...body } };
});
app.get('/users/:id', async (event) => {
const { id } = event.pathParameters || {};
return { user: { id } };
});
// ... 7+ more routes (consider splitting!)
export const handler = (event: APIGatewayProxyEvent, context: Context): Promise<APIGatewayProxyResult> => {
return app.resolve(event, context);
};
Note: Having 10+ routes in a single Lambda suggests poor function design. Lambda functions should be small and focused. Consider splitting into separate functions or using API Gateway to route to different Lambdas.
Installing Powertools
# Core utilities (always install these)
npm install @aws-lambda-powertools/logger @aws-lambda-powertools/tracer
npm install -D @types/aws-lambda
# Event Handler (only if you have 10+ routes - see routing section above)
# npm install @aws-lambda-powertools/event-handler
Integration with Other Skills
- jeff-skill-error-debugging-rca: Use when debugging errors or test failures in Angular projects or related tools
Additional Resources
- TypeScript Documentation: https://www.typescriptlang.org/docs/
- TypeScript ESLint: https://typescript-eslint.io/
- Vitest Documentation: https://vitest.dev/
- Node.js Best Practices: https://github.com/goldbergyoni/nodebestpractices
Converted and distributed by TomeVault — claim your Tome and manage your conversions.