TypeScript Code Style
General conventions for all TypeScript projects. Apply these by default.
TypeScript Configuration
tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "nodenext",
"moduleResolution": "nodenext",
"strict": true,
"noImplicitReturns": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "dist",
"stripInternal": true
}
}
Key points:
- Always enable
strict: true - Use
nodenextmodule resolution for Node.js projects - Use
stripInternal: truewith@internalJSDoc to hide implementation details from declaration files
Path Aliases
{
"compilerOptions": {
"paths": {
"~/*": ["src/*"]
}
}
}
Use ~/ prefix for internal imports. Never use relative paths that go up more than one level (../../).
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Files | kebab-case | user-service.ts |
| Classes | PascalCase | UserService |
| Interfaces/Types | PascalCase | UserSession |
| Functions/Methods | camelCase | findByEmail() |
| Constants | UPPER_SNAKE_CASE | MAX_RETRY_COUNT |
| Private class fields | # prefix |
#connection |
| Enum values | PascalCase | Status.Active |
| Boolean variables | is/has/can prefix | isActive, hasPermission |
File Naming
user.service.ts # service
user.controller.ts # controller
user.repository.ts # repository
user.entity.ts # database entity
user.test.ts # test
user.e2e.test.ts # e2e test
create-user.request.ts # request DTO
user.response.ts # response DTO
index.ts # barrel exports
Exports
Barrel Files
Use index.ts for public API of a module:
export * from './user.service'
export * from './user.entity'
export type { UserSession } from './user.types'
Export Patterns
- Use
export typefor type-only exports - Mark internal implementation with
@internalJSDoc - Prefer named exports over default exports
/** @internal */
export function internalHelper() {}
export type UserRole = 'admin' | 'user' | 'viewer'
export class UserService {}
Error Handling
Custom Error Classes
Create specific error types with type guards:
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
) {
super(message)
this.name = 'AppError'
}
}
export class ForkException extends Error {
readonly name = 'ForkError'
readonly previousBlocks: BlockCursor[]
constructor(previousBlocks: BlockCursor[]) {
super(`Fork detected at block ${previousBlocks[0]?.number}`)
this.previousBlocks = previousBlocks
}
}
export function isForkException(err: unknown): err is ForkException {
return err instanceof ForkException || (err instanceof Error && err.name === 'ForkError')
}
Error Handling Rules
- Use type guards for error discrimination — check both
instanceofandnamefor cross-boundary errors - Serialize errors with cause chains in logging using
pino.stdSerializers.errWithCause - Wrap unknown errors with
ensureError()before propagating:function ensureError(value: unknown): Error { if (value instanceof Error) return value return new Error(String(value)) } - Never silently swallow errors — at minimum, log them
- Use specific error classes — not generic
Errorfor domain errors
Configuration
Environment Variables
Use environment variables for all configuration. Validate at startup:
const config = {
port: Number(process.env.HTTP_PORT ?? 3000),
logLevel: process.env.LOG_LEVEL ?? 'info',
databaseUrl: requireEnv('DATABASE_URL'),
}
function requireEnv(name: string): string {
const value = process.env[name]
if (!value) throw new Error(`Missing required env var: ${name}`)
return value
}
Options Objects
Use options objects for component configuration with sensible defaults:
interface HttpClientOptions {
baseUrl: string
timeout?: number // default: 20_000
retryAttempts?: number // default: 3
retrySchedule?: number[] // default: [1000, 3000, 10_000]
headers?: Record<string, string>
}
Code Patterns
Prefer #private Over private
Use ES private fields for true encapsulation:
class Connection {
#client: Client
#logger: Logger
constructor(client: Client, logger: Logger) {
this.#client = client
this.#logger = logger
}
}
Numeric Literals
Use underscores for readability:
const MAX_SIZE = 10_485_760 // 10 MB
const TIMEOUT = 5_000 // 5 seconds
const IDLE_TIME = 300 // 300ms
Async Patterns
- Always handle promises — use
@typescript-eslint/no-floating-promises - Use
AsyncLocalStoragefor implicit context passing in pipelines:import { AsyncLocalStorage } from 'node:async_hooks' const asyncLocalStorage = new AsyncLocalStorage<RuntimeContext>() export function runWithContext<T>(ctx: RuntimeContext, fn: () => Promise<T>): Promise<T> { return asyncLocalStorage.run(ctx, fn) } export function useContext(): RuntimeContext { const ctx = asyncLocalStorage.getStore() if (!ctx) throw new Error('No runtime context') return ctx }
Composition Over Inheritance
Use composable transformers/middleware instead of deep class hierarchies:
// Good: composable pipeline
const pipeline = source
.pipe(decode())
.pipe(transform())
.pipe(filter())
// Bad: deep inheritance
class SpecializedTransformer extends BaseTransformer extends AbstractTransformer {}
Builder Pattern for Complex Configuration
const query = evmQueryBuilder()
.addFields({ logs: { address: true, topics: true } })
.setRange({ from: 1_000_000 })
.build()
Factory Functions Over Constructors
Prefer factory functions when the construction logic is complex or return types vary:
// Good
export function createLogger(options?: LoggerOptions): Logger {
return pino({ ...defaults, ...options })
}
// Instead of exposing constructor directly
Build Setup
tsup (for Libraries)
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
outDir: 'dist',
format: ['cjs', 'esm'],
sourcemap: true,
dts: true,
clean: true,
})
Package.json Exports (Dual CJS/ESM)
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
Things to Avoid
- No
namespace— use ES modules - No
enum— useas constobjects or union types:// Good const Status = { Active: 'active', Inactive: 'inactive' } as const type Status = (typeof Status)[keyof typeof Status] // Bad enum Status { Active = 'active', Inactive = 'inactive' } - No
anyin public APIs — useunknownand narrow - No
==— always=== - No default exports — use named exports
- No relative imports beyond one level — use path aliases (
~/) - No magic numbers — extract to named constants
- No classes for simple data — use plain objects and types