
Typescript Project
- 75 installs
- 253 repo stars
- Updated August 4, 2026
- majiayu000/claude-arsenal
Helps with ai & agent building tasks during AI-assisted development.
About
typescript-project is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- typescript-project
- AI & Agent Building
- AI-coding skill
Typescript Project by the numbers
- 75 all-time installs (skills.sh)
- Ranked #5,486 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/majiayu000/claude-arsenal --skill typescript-projectAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 75 |
|---|---|
| repo stars | ★ 253 |
| Last updated | August 4, 2026 |
| Repository | majiayu000/claude-arsenal ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
TypeScript Project Architecture
Core Principles
- Type safety first — Strict mode, no
any, Zod for runtime validation - ESM native — ES Modules by default, Node 22+ / Bun
- Layered architecture — Separate lib/services/adapters
- 200-line limit — No file exceeds 200 lines (see elegant-architecture skill)
- Test reality — Vitest/Bun test, minimal mocks
- No backwards compatibility — Delete, don't deprecate. Change directly, no shims
- LiteLLM for LLM APIs — Use LiteLLM proxy for all LLM integrations, unless specific SDK required
---
No Backwards Compatibility
Delete unused code. Change directly. No compatibility layers.
Why
- Dead code is tech debt
- Compatibility shims add complexity
- Old patterns spread through copy-paste
- "Temporary" workarounds become permanent
Anti-Patterns to Avoid
// ❌ BAD: Renaming but keeping old export
export { newName };
export { newName as oldName }; // "for backwards compatibility"
// ❌ BAD: Unused parameter with underscore
function process(_legacyParam: string, data: Data) { ... }
// ❌ BAD: Deprecated comments instead of deletion
/** @deprecated Use newMethod instead */
export function oldMethod() { ... }
// ❌ BAD: Re-exporting removed functionality
export { removed } from './legacy'; // Keep for existing consumers
// ❌ BAD: Feature flags for old behavior
if (config.useLegacyMode) { ... }Correct Approach
// ✅ GOOD: Just delete and update all usages
// Old: export { fetchData as getData }
// New: export { fetchData }
// Then: Find & replace all getData → fetchData
// ✅ GOOD: Remove unused parameters entirely
function process(data: Data) { ... }
// ✅ GOOD: Delete deprecated code, update callers
// Don't mark as deprecated, just remove it
// ✅ GOOD: Breaking changes are fine in active development
// Semantic versioning handles this for librariesWhen Changing Interfaces
// ❌ BAD: Adding optional fields "for compatibility"
interface User {
id: string;
name: string;
firstName?: string; // New field, name kept for compatibility
lastName?: string;
}
// ✅ GOOD: Clean break, update all usages
interface User {
id: string;
firstName: string;
lastName: string;
}
// Then update ALL code that uses User.nameMigration Strategy
1. Find all usages — grep -r "oldName" src/ 2. Update all at once — Single commit, no transition period 3. Delete old code — No deprecation warnings, just remove 4. Run tests — Ensure nothing breaks
---
LiteLLM for LLM APIs
Use LiteLLM proxy for all LLM integrations. Don't call provider APIs directly.
Why LiteLLM
- Unified interface — One API for 100+ LLM providers (OpenAI, Anthropic, Azure, Bedrock, etc.)
- Provider agnostic — Switch models without code changes
- Cost tracking — Built-in usage and cost monitoring
- Load balancing — Automatic failover between providers
- Rate limiting — Protect against quota exhaustion
Setup
# Run LiteLLM proxy (Docker)
docker run -p 4000:4000 ghcr.io/berriai/litellm:main-stable
# Or install locally
pip install litellm[proxy]
litellm --model gpt-4oTypeScript Usage
// adapters/llm.adapter.ts
import { OpenAI } from 'openai';
// Connect to LiteLLM proxy using OpenAI SDK
const llm = new OpenAI({
baseURL: process.env.LITELLM_URL || 'http://localhost:4000',
apiKey: process.env.LITELLM_API_KEY || 'sk-1234', // Proxy API key
});
export async function complete(prompt: string, model = 'gpt-4o'): Promise<string> {
const response = await llm.chat.completions.create({
model, // Can be any model: gpt-4o, claude-3-opus, gemini-pro, etc.
messages: [{ role: 'user', content: prompt }],
});
return response.choices[0]?.message?.content ?? '';
}When NOT to Use LiteLLM
- Streaming with provider-specific features (e.g., Anthropic's tool use streaming)
- Provider-specific APIs not in OpenAI format (embeddings with metadata, etc.)
- Direct SDK required for compliance/security reasons
Anti-Patterns
// ❌ BAD: Direct provider SDKs everywhere
import Anthropic from '@anthropic-ai/sdk';
import OpenAI from 'openai';
import { GoogleGenerativeAI } from '@google/generative-ai';
// ❌ BAD: Provider-specific code scattered across codebase
if (provider === 'anthropic') { ... }
else if (provider === 'openai') { ... }
// ✅ GOOD: Single LiteLLM adapter, switch models via config
const response = await llm.chat.completions.create({
model: config.llmModel, // "gpt-4o" or "claude-3-opus" or "gemini-pro"
messages,
});---
Quick Start
1. Initialize Project
# Using Bun (recommended)
bun init
bun add zod
bun add -d typescript @types/bun @biomejs/biome
# Using Node.js
npm init -y
npm i zod
npm i -D typescript @types/node tsx @biomejs/biome2. Apply Tech Stack
| Layer | Recommendation |
|---|---|
| Runtime | Bun / Node 22+ |
| Language | TypeScript (latest) |
| Validation | Zod (latest) |
| Testing | Bun test / Vitest |
| Build | bun build / tsup |
| Linting | Biome (latest) |
Version Strategy
Always use latest. Never pin versions in templates.
{
"dependencies": {
"zod": "latest"
},
"devDependencies": {
"@biomejs/biome": "latest",
"typescript": "latest"
}
}bun add/npm iautomatically fetches latest- Use
bun update --latestto upgrade all dependencies - Lock files (
bun.lockb,package-lock.json) ensure reproducible builds - Breaking changes are handled by reading changelogs, not by avoiding updates
3. Use Standard Structure
project/
├── src/
│ ├── index.ts # Entry point
│ ├── lib/ # Core utilities
│ │ ├── config.ts # Configuration management
│ │ ├── errors.ts # Custom error classes
│ │ ├── logger.ts # Logging infrastructure
│ │ └── types.ts # Shared type definitions
│ ├── services/ # Business logic
│ │ └── *.service.ts
│ └── adapters/ # External integrations
│ └── *.adapter.ts
├── tests/ # Test files
│ └── *.test.ts
├── tsconfig.json
├── package.json
└── biome.json # or eslint.config.js---
Architecture Layers
lib/ — Core Infrastructure
Foundational code used across the entire application:
// lib/types.ts — Shared type definitions
export interface Result<T, E = Error> {
ok: boolean;
data?: T;
error?: E;
}
// lib/errors.ts — Custom errors
export class AppError extends Error {
constructor(
message: string,
public code: string,
public statusCode: number = 500
) {
super(message);
this.name = 'AppError';
}
}
// lib/config.ts — Configuration
export const config = {
env: process.env.NODE_ENV || 'development',
port: Number(process.env.PORT) || 3000,
db: {
url: process.env.DATABASE_URL!,
},
} as const;
// lib/logger.ts — Logging (see structured-logging skill)services/ — Business Logic
Pure business logic with injected dependencies:
// services/user.service.ts
export class UserService {
constructor(private readonly userRepo: UserRepository) {}
async create(input: CreateUserInput): Promise<User> {
const existing = await this.userRepo.findByEmail(input.email);
if (existing) throw new AppError('Email exists', 'USER_EXISTS', 409);
return this.userRepo.save(User.create(input));
}
}adapters/ — External Integrations
Interface with external systems (DB, APIs, file system):
// adapters/postgres.adapter.ts
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: Database) {}
async findByEmail(email: string): Promise<User | null> {
const row = await this.db.query('SELECT * FROM users WHERE email = $1', [email]);
return row ? User.fromRow(row) : null;
}
}---
Configuration Files
tsconfig.json (2025)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}package.json
{
"name": "my-project",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun dist/index.js",
"test": "bun test",
"typecheck": "tsc --noEmit"
}
}---
Validation with Zod
import { z } from 'zod';
// Define schemas
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().positive().optional(),
});
// Infer types from schemas
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
// Validate at boundaries
export function validateInput<T>(schema: z.ZodType<T>, data: unknown): T {
return schema.parse(data);
}---
Error Handling Pattern
// lib/errors.ts
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
static notFound(resource: string, id: string) {
return new AppError(`${resource} not found: ${id}`, 'NOT_FOUND', 404);
}
static validation(message: string, context?: Record<string, unknown>) {
return new AppError(message, 'VALIDATION_ERROR', 400, context);
}
}
// Usage
throw AppError.notFound('User', userId);---
Testing Strategy
// tests/user.service.test.ts
import { describe, it, expect, beforeEach } from 'bun:test';
import { UserService } from '../src/services/user.service';
import { InMemoryUserRepository } from './helpers/in-memory-repo';
describe('UserService', () => {
let service: UserService;
let repo: InMemoryUserRepository;
beforeEach(() => {
repo = new InMemoryUserRepository();
service = new UserService(repo);
});
it('creates user with valid input', async () => {
const user = await service.create({
email: 'test@example.com',
name: 'Test User',
});
expect(user.email).toBe('test@example.com');
expect(await repo.findByEmail('test@example.com')).toEqual(user);
});
it('rejects duplicate email', async () => {
await service.create({ email: 'test@example.com', name: 'User 1' });
expect(
service.create({ email: 'test@example.com', name: 'User 2' })
).rejects.toThrow('Email exists');
});
});---
Checklist
## Project Setup
- [ ] TypeScript strict mode enabled
- [ ] ESM modules configured
- [ ] Biome/ESLint configured
- [ ] Testing framework ready
## Architecture
- [ ] lib/ for core utilities
- [ ] services/ for business logic
- [ ] adapters/ for external integrations
- [ ] Clear module boundaries
## Quality
- [ ] Zod schemas for validation
- [ ] Custom error classes
- [ ] Structured logging
- [ ] Tests for critical paths
## Build
- [ ] Build script configured
- [ ] Type checking in CI
- [ ] Tests in CI---
See Also
- reference/architecture.md — Detailed architecture patterns
- reference/tech-stack.md — Tech stack comparison
- reference/patterns.md — Design patterns
- elegant-architecture skill — 200-line file limit
- structured-logging skill — Logging setup
Architecture Reference
Table of Contents
1. Layered Architecture 2. Directory Structure Patterns 3. Module Organization 4. Dependency Flow 5. Composition Root
---
Layered Architecture
Overview
┌─────────────────────────────────────────────────────────────┐
│ ENTRY POINTS │
│ CLI / HTTP Server / Message Queue Consumer │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SERVICES │
│ Business Logic & Use Cases │
│ UserService, OrderService, PaymentService │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ADAPTERS │
│ External System Integrations │
│ PostgresRepo, StripeGateway, S3Storage, RedisCache │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LIB │
│ Core Infrastructure │
│ Config, Logger, Errors, Types, Utils │
└─────────────────────────────────────────────────────────────┘Layer Responsibilities
| Layer | Responsibility | Example |
|---|---|---|
| Entry | Handle I/O, routing, serialization | HTTP handlers, CLI commands |
| Services | Business rules, orchestration | UserService.register() |
| Adapters | External system communication | Database queries, API calls |
| Lib | Shared utilities, types | Config, logging, error classes |
Dependency Rule
Dependencies point inward. Entry → Services → Adapters → Lib
- Services depend on Adapter interfaces, not implementations
- Adapters depend on Lib, never on Services
- Lib depends on nothing internal
---
Directory Structure Patterns
Pattern A: Flat (Small Projects)
src/
├── index.ts
├── config.ts
├── logger.ts
├── types.ts
├── user.service.ts
├── user.repository.ts
└── user.controller.tsBest for: < 10 files, single developer, simple CRUD
Pattern B: Layered (Medium Projects)
src/
├── index.ts
├── lib/
│ ├── config.ts
│ ├── errors.ts
│ ├── logger.ts
│ └── types.ts
├── services/
│ ├── user.service.ts
│ └── order.service.ts
├── adapters/
│ ├── postgres/
│ │ ├── user.repository.ts
│ │ └── order.repository.ts
│ └── stripe/
│ └── payment.gateway.ts
└── http/
├── server.ts
├── routes/
└── middleware/Best for: 10-50 files, small team, multiple integrations
Pattern C: Feature Modules (Large Projects)
src/
├── index.ts
├── shared/
│ ├── lib/
│ ├── types/
│ └── middleware/
├── modules/
│ ├── user/
│ │ ├── index.ts # Public exports
│ │ ├── user.types.ts
│ │ ├── user.service.ts
│ │ ├── user.repository.ts
│ │ └── user.controller.ts
│ ├── order/
│ │ ├── index.ts
│ │ ├── order.types.ts
│ │ ├── order.service.ts
│ │ └── order.repository.ts
│ └── payment/
│ ├── index.ts
│ ├── payment.types.ts
│ └── payment.service.ts
└── http/
└── server.tsBest for: > 50 files, multiple teams, complex domain
---
Module Organization
Single File Module
// user.service.ts (< 200 lines)
export interface UserService { ... }
export class UserServiceImpl implements UserService { ... }
export function createUserService(deps: UserServiceDeps): UserService { ... }Folder Module
When a module exceeds 200 lines, convert to folder:
user/
├── index.ts # Public API only
├── types.ts # Interfaces, types
├── service.ts # Business logic
├── repository.ts # Data access
├── validation.ts # Input validation
└── errors.ts # Domain-specific errorsIndex File Pattern
// user/index.ts — Only exports, no implementation
export type { User, CreateUserInput, UserService } from './types';
export { UserServiceImpl } from './service';
export { PostgresUserRepository } from './repository';
export { createUserModule } from './factory';---
Dependency Flow
Interface Segregation
// services/user.service.ts
// Define what you NEED, not what exists
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<User>;
}
export class UserService {
constructor(private readonly repo: UserRepository) {}
}
// adapters/postgres/user.repository.ts
// Implement what services need
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: Database) {}
async findById(id: string): Promise<User | null> { ... }
async save(user: User): Promise<User> { ... }
}Dependency Injection
// Constructor injection (preferred)
class OrderService {
constructor(
private readonly orderRepo: OrderRepository,
private readonly userService: UserService,
private readonly paymentGateway: PaymentGateway
) {}
}
// Factory function injection
function createOrderService(deps: {
orderRepo: OrderRepository;
userService: UserService;
paymentGateway: PaymentGateway;
}): OrderService {
return new OrderService(deps.orderRepo, deps.userService, deps.paymentGateway);
}---
Composition Root
Wire dependencies at application startup:
// src/index.ts or src/container.ts
import { config } from './lib/config';
import { createDatabase } from './lib/database';
import { PostgresUserRepository } from './adapters/postgres/user.repository';
import { StripePaymentGateway } from './adapters/stripe/payment.gateway';
import { UserService } from './services/user.service';
import { OrderService } from './services/order.service';
import { createHttpServer } from './http/server';
export async function bootstrap() {
// 1. Infrastructure
const db = await createDatabase(config.db);
// 2. Adapters
const userRepo = new PostgresUserRepository(db);
const paymentGateway = new StripePaymentGateway(config.stripe);
// 3. Services
const userService = new UserService(userRepo);
const orderService = new OrderService(orderRepo, userService, paymentGateway);
// 4. Entry points
const server = createHttpServer({
userService,
orderService,
});
await server.listen(config.port);
}
bootstrap();Benefits
- All dependencies visible in one place
- Easy to swap implementations (testing, different environments)
- No hidden global state
- Clear startup sequence
Design Patterns Reference
Table of Contents
1. Result Pattern 2. Repository Pattern 3. Factory Pattern 4. Strategy Pattern 5. Builder Pattern 6. Middleware Pattern 7. Event-Driven Pattern
---
Result Pattern
Handle success/failure without exceptions:
// lib/result.ts
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Result = {
ok: <T>(value: T): Result<T, never> => ({ ok: true, value }),
err: <E>(error: E): Result<never, E> => ({ ok: false, error }),
map: <T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> =>
result.ok ? Result.ok(fn(result.value)) : result,
flatMap: <T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> => (result.ok ? fn(result.value) : result),
unwrap: <T, E>(result: Result<T, E>): T => {
if (!result.ok) throw result.error;
return result.value;
},
};
// Usage
async function findUser(id: string): Promise<Result<User, AppError>> {
const user = await db.users.findById(id);
if (!user) return Result.err(AppError.notFound('User', id));
return Result.ok(user);
}
const result = await findUser('123');
if (!result.ok) {
return res.status(404).json({ error: result.error.message });
}
const user = result.value;---
Repository Pattern
Abstract data access behind interfaces:
// types/repository.ts
export interface Repository<T, ID = string> {
findById(id: ID): Promise<T | null>;
findAll(): Promise<T[]>;
save(entity: T): Promise<T>;
delete(id: ID): Promise<void>;
}
// services/user.repository.ts
export interface UserRepository extends Repository<User> {
findByEmail(email: string): Promise<User | null>;
findByRole(role: UserRole): Promise<User[]>;
}
// adapters/postgres/user.repository.ts
export class PostgresUserRepository implements UserRepository {
constructor(private readonly db: Database) {}
async findById(id: string): Promise<User | null> {
const row = await this.db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return row ? this.toEntity(row) : null;
}
async findByEmail(email: string): Promise<User | null> {
const row = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return row ? this.toEntity(row) : null;
}
async save(user: User): Promise<User> {
const row = await this.db.query(
`INSERT INTO users (id, email, name, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET email = $2, name = $3
RETURNING *`,
[user.id, user.email, user.name, user.createdAt]
);
return this.toEntity(row);
}
private toEntity(row: UserRow): User {
return new User(row.id, row.email, row.name, row.created_at);
}
}
// Testing with in-memory implementation
export class InMemoryUserRepository implements UserRepository {
private users = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.users.get(id) ?? null;
}
async save(user: User): Promise<User> {
this.users.set(user.id, user);
return user;
}
// ...
}---
Factory Pattern
Encapsulate complex object creation:
// Simple Factory
export function createLogger(config: LogConfig): Logger {
const transports: Transport[] = [];
if (config.console) {
transports.push(new ConsoleTransport(config.level));
}
if (config.file) {
transports.push(new FileTransport(config.file));
}
if (config.remote) {
transports.push(new RemoteTransport(config.remote));
}
return new Logger(transports);
}
// Factory with Registry
class NotificationFactory {
private registry = new Map<string, NotificationBuilder>();
register(type: string, builder: NotificationBuilder) {
this.registry.set(type, builder);
}
create(type: string, data: NotificationData): Notification {
const builder = this.registry.get(type);
if (!builder) throw new Error(`Unknown type: ${type}`);
return builder(data);
}
}
const factory = new NotificationFactory();
factory.register('email', (data) => new EmailNotification(data));
factory.register('sms', (data) => new SmsNotification(data));
factory.register('push', (data) => new PushNotification(data));
const notification = factory.create('email', { to: 'user@example.com' });
// Abstract Factory
interface DatabaseFactory {
createConnection(): Connection;
createQueryBuilder(): QueryBuilder;
createMigrator(): Migrator;
}
class PostgresFactory implements DatabaseFactory {
createConnection() { return new PostgresConnection(); }
createQueryBuilder() { return new PostgresQueryBuilder(); }
createMigrator() { return new PostgresMigrator(); }
}
class SqliteFactory implements DatabaseFactory {
createConnection() { return new SqliteConnection(); }
createQueryBuilder() { return new SqliteQueryBuilder(); }
createMigrator() { return new SqliteMigrator(); }
}---
Strategy Pattern
Swap algorithms at runtime:
// Define strategy interface
interface PricingStrategy {
calculate(order: Order): Money;
}
// Concrete strategies
class StandardPricing implements PricingStrategy {
calculate(order: Order): Money {
return order.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
}
class DiscountPricing implements PricingStrategy {
constructor(private readonly discount: Percentage) {}
calculate(order: Order): Money {
const subtotal = new StandardPricing().calculate(order);
return subtotal.subtract(subtotal.multiply(this.discount));
}
}
class TieredPricing implements PricingStrategy {
calculate(order: Order): Money {
const subtotal = new StandardPricing().calculate(order);
const discount = this.getTierDiscount(subtotal);
return subtotal.subtract(subtotal.multiply(discount));
}
private getTierDiscount(amount: Money): Percentage {
if (amount.greaterThan(Money.of(1000))) return Percentage.of(20);
if (amount.greaterThan(Money.of(500))) return Percentage.of(10);
if (amount.greaterThan(Money.of(100))) return Percentage.of(5);
return Percentage.zero();
}
}
// Context
class OrderProcessor {
constructor(private strategy: PricingStrategy) {}
setStrategy(strategy: PricingStrategy) {
this.strategy = strategy;
}
process(order: Order): ProcessedOrder {
const total = this.strategy.calculate(order);
return { ...order, total };
}
}
// Usage
const processor = new OrderProcessor(new StandardPricing());
processor.setStrategy(new DiscountPricing(Percentage.of(15)));
const result = processor.process(order);---
Builder Pattern
Construct complex objects step by step:
// Fluent Builder
class QueryBuilder<T> {
private query: QueryConfig = { table: '', conditions: [], orderBy: [] };
from(table: string): this {
this.query.table = table;
return this;
}
where(field: string, op: Operator, value: unknown): this {
this.query.conditions.push({ field, op, value });
return this;
}
orderBy(field: string, direction: 'asc' | 'desc' = 'asc'): this {
this.query.orderBy.push({ field, direction });
return this;
}
limit(n: number): this {
this.query.limit = n;
return this;
}
build(): Query<T> {
if (!this.query.table) throw new Error('Table required');
return new Query(this.query);
}
}
// Usage
const query = new QueryBuilder<User>()
.from('users')
.where('status', '=', 'active')
.where('age', '>', 18)
.orderBy('createdAt', 'desc')
.limit(10)
.build();
// Builder with Director
class HttpRequestBuilder {
private request: Partial<HttpRequest> = {};
method(m: HttpMethod): this {
this.request.method = m;
return this;
}
url(u: string): this {
this.request.url = u;
return this;
}
header(key: string, value: string): this {
this.request.headers = { ...this.request.headers, [key]: value };
return this;
}
json(data: unknown): this {
this.request.body = JSON.stringify(data);
return this.header('Content-Type', 'application/json');
}
build(): HttpRequest {
if (!this.request.method || !this.request.url) {
throw new Error('Method and URL required');
}
return this.request as HttpRequest;
}
}
// Director for common patterns
const ApiRequest = {
get: (url: string) =>
new HttpRequestBuilder().method('GET').url(url).build(),
postJson: (url: string, data: unknown) =>
new HttpRequestBuilder().method('POST').url(url).json(data).build(),
};---
Middleware Pattern
Chain processing functions:
// Type definitions
type Next = () => Promise<void>;
type Middleware<C> = (ctx: C, next: Next) => Promise<void>;
// Middleware composer
function compose<C>(middlewares: Middleware<C>[]): Middleware<C> {
return async (ctx, next) => {
let index = -1;
async function dispatch(i: number): Promise<void> {
if (i <= index) throw new Error('next() called multiple times');
index = i;
const fn = i < middlewares.length ? middlewares[i] : next;
if (fn) await fn(ctx, () => dispatch(i + 1));
}
await dispatch(0);
};
}
// Example middlewares
const logger: Middleware<Context> = async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.path} - ${ms}ms`);
};
const errorHandler: Middleware<Context> = async (ctx, next) => {
try {
await next();
} catch (error) {
ctx.status = error.statusCode || 500;
ctx.body = { error: error.message };
}
};
const auth: Middleware<Context> = async (ctx, next) => {
const token = ctx.headers.authorization?.replace('Bearer ', '');
if (!token) throw new UnauthorizedError();
ctx.user = await verifyToken(token);
await next();
};
// Usage
const app = compose([errorHandler, logger, auth, router]);---
Event-Driven Pattern
Decouple components with events:
// Event definitions
interface DomainEvents {
'user.created': { userId: string; email: string };
'user.verified': { userId: string };
'order.placed': { orderId: string; userId: string; total: number };
'order.shipped': { orderId: string; trackingNumber: string };
}
// Type-safe event emitter
class EventBus<Events extends Record<string, unknown>> {
private handlers = new Map<keyof Events, Set<Function>>();
on<K extends keyof Events>(
event: K,
handler: (payload: Events[K]) => void | Promise<void>
): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)?.delete(handler);
}
async emit<K extends keyof Events>(event: K, payload: Events[K]): Promise<void> {
const handlers = this.handlers.get(event);
if (!handlers) return;
await Promise.all(
Array.from(handlers).map((handler) => handler(payload))
);
}
}
// Usage
const events = new EventBus<DomainEvents>();
// Subscribe
events.on('user.created', async ({ userId, email }) => {
await sendWelcomeEmail(email);
});
events.on('user.created', async ({ userId }) => {
await createDefaultSettings(userId);
});
events.on('order.placed', async ({ orderId, userId }) => {
await notifyUser(userId, `Order ${orderId} confirmed`);
});
// Emit from service
class UserService {
constructor(
private readonly repo: UserRepository,
private readonly events: EventBus<DomainEvents>
) {}
async create(input: CreateUserInput): Promise<User> {
const user = await this.repo.save(User.create(input));
await this.events.emit('user.created', {
userId: user.id,
email: user.email,
});
return user;
}
}---
Pattern Selection Guide
| Scenario | Pattern |
|---|---|
| Handle errors without exceptions | Result |
| Abstract data access | Repository |
| Complex object creation | Factory |
| Swappable algorithms | Strategy |
| Step-by-step construction | Builder |
| Request/response processing | Middleware |
| Decouple components | Event-Driven |
Tech Stack Reference
Table of Contents
1. Version Strategy 2. Runtime 3. Build Tools 4. Validation 5. Testing 6. Linting & Formatting 7. Database 8. HTTP Framework 9. Decision Matrix
---
Version Strategy
Always use `latest`. This document describes capabilities, not version numbers.
Why No Pinned Versions
- Version numbers become outdated immediately
bun add/npm iautomatically fetches latest stable- Lock files ensure reproducible builds
- Breaking changes are expected and handled by reading changelogs
How to Stay Current
# Check outdated packages
bun outdated
npm outdated
# Upgrade all to latest
bun update --latest
npm update --latest
# Check for breaking changes
# Read CHANGELOG.md or release notes before major upgradesPackage Installation
# Always install without version specifier
bun add zod # Gets latest
bun add -d @biomejs/biome # Gets latest
# Never do this in templates
bun add zod@3.23.0 # Pinned = outdated tomorrow---
Runtime
Bun (Recommended for new projects)
# Install
curl -fsSL https://bun.sh/install | bash
# Create project
bun init
# Run
bun run src/index.ts
# Build
bun build src/index.ts --outdir dist --target bunPros:
- 4x faster than Node.js
- Native TypeScript support (no transpilation)
- Built-in bundler, test runner, package manager
- Drop-in Node.js compatibility
Cons:
- Younger ecosystem
- Some Node.js APIs not 100% compatible
- Less battle-tested in production
Node.js 22+ (Stable choice)
# With tsx for TypeScript
npm i -D typescript tsx @types/node
# Run
npx tsx src/index.ts
# Or with ts-node
npx ts-node --esm src/index.tsPros:
- Most stable, battle-tested
- Largest ecosystem
- Native ESM support in v22+
- Built-in test runner
Cons:
- Slower than Bun
- Requires transpilation setup
Recommendation
| Scenario | Choice |
|---|---|
| New project, greenfield | Bun |
| Enterprise, legacy integration | Node.js 22 |
| Serverless (AWS Lambda) | Node.js 22 |
| Edge functions (Cloudflare) | Bun |
---
Build Tools
tsup (Simple, fast)
npm i -D tsup// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
sourcemap: true,
});Best for: Libraries, CLIs, simple projects
unbuild (Universal)
npm i -D unbuild// build.config.ts
import { defineBuildConfig } from 'unbuild';
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
clean: true,
rollup: {
emitCJS: true,
},
});Best for: Libraries needing CJS + ESM dual output
Bun build (Zero config)
bun build src/index.ts --outdir dist --target bunBest for: Bun-only projects, fastest builds
Comparison
| Tool | Speed | Config | DTS | Watch |
|---|---|---|---|---|
| tsup | Fast | Minimal | Yes | Yes |
| unbuild | Medium | Minimal | Yes | No |
| bun build | Fastest | Zero | No | No |
| esbuild | Fastest | Manual | No | Yes |
| tsc | Slow | tsconfig | Yes | Yes |
---
Validation
Zod (Recommended)
bun add zodimport { z } from 'zod';
const UserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
age: z.number().int().positive().optional(),
role: z.enum(['admin', 'user']).default('user'),
});
type User = z.infer<typeof UserSchema>;
// Parse (throws on error)
const user = UserSchema.parse(rawData);
// Safe parse (returns result)
const result = UserSchema.safeParse(rawData);
if (!result.success) {
console.error(result.error.issues);
}Zod 4+ Features:
- 57% smaller bundle size
- 20x faster type-checking in IDE
z.templateLiteral()for template literal types@zod/minifor edge/serverless (minimal bundle)
Pros:
- TypeScript-first design
- Excellent type inference
- Composable schemas
- Great error messages
Valibot (Lightweight alternative)
bun add valibotimport * as v from 'valibot';
const UserSchema = v.object({
email: v.pipe(v.string(), v.email()),
name: v.pipe(v.string(), v.minLength(2)),
});
type User = v.InferOutput<typeof UserSchema>;Pros: Smaller bundle than Zod (use when bundle size is critical)
Comparison
| Library | Performance | DX | Best For |
|---|---|---|---|
| Zod | Excellent | Excellent | Default choice |
| Valibot | Excellent | Good | Bundle-critical |
| @zod/mini | Excellent | Good | Edge/serverless |
---
Testing
Vitest (Recommended for Node.js)
npm i -D vitest// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
},
},
});// user.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
describe('UserService', () => {
it('creates user', async () => {
const user = await service.create({ email: 'test@test.com' });
expect(user.email).toBe('test@test.com');
});
});Bun Test (Recommended for Bun)
// user.test.ts
import { describe, it, expect, beforeEach } from 'bun:test';
describe('UserService', () => {
it('creates user', async () => {
const user = await service.create({ email: 'test@test.com' });
expect(user.email).toBe('test@test.com');
});
});bun test
bun test --coverageComparison
| Framework | Speed | Watch | Coverage | Mocking |
|---|---|---|---|---|
| Vitest | Fast | Yes | V8/Istanbul | Built-in |
| Bun test | Fastest | Yes | Built-in | Built-in |
| Jest | Medium | Yes | Istanbul | Built-in |
| Node test | Fast | Yes | V8 | Manual |
---
Linting & Formatting
Biome (Recommended)
bun add -D @biomejs/biome
npx @biomejs/biome init// biome.json
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"assists": { "enabled": true },
"organizeImports": { "enabled": true },
"linter": {
"enabled": true,
"rules": { "recommended": true }
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
}
}Biome 2.0+ Features:
- Type-aware linting without TypeScript compiler
- Multi-file analysis and project indexing
- 340+ lint rules
- CSS and GraphQL support
- 97% Prettier compatibility
Pros: 100x faster than ESLint + Prettier, single tool
ESLint + Prettier (Traditional)
npm i -D eslint prettier @typescript-eslint/parser @typescript-eslint/eslint-pluginPros: Largest ecosystem, most plugins
Comparison
| Tool | Speed | Plugins | Config |
|---|---|---|---|
| Biome | 100x faster | Limited | Simple |
| ESLint | Slow | Extensive | Complex |
| oxlint | Fast | Growing | Simple |
---
Database
Drizzle ORM (Recommended)
bun add drizzle-orm
bun add -D drizzle-kit// schema.ts
import { pgTable, serial, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: serial('id').primaryKey(),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
});
// queries
const allUsers = await db.select().from(users);
const user = await db.select().from(users).where(eq(users.id, 1));Pros: Type-safe, SQL-like syntax, lightweight
Prisma (Alternative)
npm i prisma @prisma/clientPros: Great DX, migrations, Prisma Studio
Cons: Heavier, slower cold starts
Comparison
| ORM | Type Safety | Performance | Learning Curve |
|---|---|---|---|
| Drizzle | Excellent | Excellent | Medium |
| Prisma | Excellent | Good | Low |
| Kysely | Excellent | Excellent | Medium |
| TypeORM | Good | Medium | High |
---
HTTP Framework
Hono (Recommended)
bun add honoimport { Hono } from 'hono';
const app = new Hono();
app.get('/users/:id', async (c) => {
const id = c.req.param('id');
const user = await userService.findById(id);
return c.json(user);
});
export default app;Pros: Ultrafast, works everywhere (Bun, Node, Cloudflare, Deno)
Fastify (Alternative)
npm i fastifyPros: Fast, mature ecosystem, validation built-in
Comparison
| Framework | Performance | Ecosystem | Portability |
|---|---|---|---|
| Hono | Fastest | Growing | Universal |
| Fastify | Very Fast | Large | Node only |
| Express | Slow | Largest | Node only |
| Elysia | Fastest | Small | Bun only |
---
Decision Matrix
For New Projects
| Layer | Recommended | Alternative |
|---|---|---|
| Runtime | Bun | Node.js (LTS) |
| Build | bun build / tsup | unbuild |
| Validation | Zod | Valibot / @zod/mini |
| Testing | Bun test / Vitest | Node test runner |
| Linting | Biome | ESLint |
| Database | Drizzle | Prisma |
| HTTP | Hono | Fastify |
Stack Combinations
Speed-optimized (Bun stack):
Bun + Hono + Drizzle + Zod + BiomeStability-optimized (Node stack):
Node 22 + Fastify + Prisma + Zod + ESLintMinimal bundle (Edge stack):
Bun + Hono + Valibot + Biome{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"assists": {
"enabled": true
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"complexity": {
"noExcessiveCognitiveComplexity": "warn"
},
"style": {
"noNonNullAssertion": "warn"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2,
"lineWidth": 100
},
"files": {
"ignore": ["node_modules", "dist", "coverage"]
}
}
{
"name": "{{PROJECT_NAME}}",
"version": "1.0.0",
"description": "{{PROJECT_DESCRIPTION}}",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun",
"start": "bun dist/index.js",
"test": "bun test",
"test:watch": "bun test --watch",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
"upgrade": "bun update --latest"
},
"dependencies": {
"openai": "latest",
"zod": "latest"
},
"devDependencies": {
"@biomejs/biome": "latest",
"@types/bun": "latest",
"typescript": "latest"
}
}
/**
* In-memory repository implementation
*
* Useful for:
* - Testing without external dependencies
* - Prototyping before adding a real database
* - Simple applications that don't need persistence
*/
import type { Example, ExampleRepository } from '../services/example.service.js';
export class InMemoryExampleRepository implements ExampleRepository {
private store = new Map<string, Example>();
async findById(id: string): Promise<Example | null> {
return this.store.get(id) ?? null;
}
async findAll(): Promise<Example[]> {
return Array.from(this.store.values());
}
async save(example: Example): Promise<Example> {
this.store.set(example.id, example);
return example;
}
async delete(id: string): Promise<void> {
this.store.delete(id);
}
// Test helpers
clear(): void {
this.store.clear();
}
size(): number {
return this.store.size;
}
}
/**
* LLM Adapter - Unified interface for all LLM providers via LiteLLM proxy
*
* Uses OpenAI SDK to connect to LiteLLM proxy, which supports:
* - OpenAI (gpt-4o, gpt-4-turbo, etc.)
* - Anthropic (claude-3-opus, claude-3-sonnet, etc.)
* - Google (gemini-pro, gemini-ultra, etc.)
* - Azure OpenAI
* - AWS Bedrock
* - And 100+ more providers
*
* @see https://docs.litellm.ai/docs/providers
*/
import { OpenAI } from 'openai';
import { config } from '../lib/config.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('llm');
// Connect to LiteLLM proxy using OpenAI-compatible SDK
const client = new OpenAI({
baseURL: config.llm.baseUrl,
apiKey: config.llm.apiKey,
});
export interface CompletionOptions {
model?: string;
temperature?: number;
maxTokens?: number;
systemPrompt?: string;
}
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
/**
* Generate a chat completion
*/
export async function complete(
prompt: string,
options: CompletionOptions = {}
): Promise<string> {
const {
model = config.llm.defaultModel,
temperature = 0.7,
maxTokens = 1000,
systemPrompt,
} = options;
const messages: Message[] = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
return chat(messages, { model, temperature, maxTokens });
}
/**
* Multi-turn chat completion
*/
export async function chat(
messages: Message[],
options: Omit<CompletionOptions, 'systemPrompt'> = {}
): Promise<string> {
const {
model = config.llm.defaultModel,
temperature = 0.7,
maxTokens = 1000,
} = options;
log.debug('LLM request', { model, messageCount: messages.length });
const response = await client.chat.completions.create({
model,
messages,
temperature,
max_tokens: maxTokens,
});
const content = response.choices[0]?.message?.content ?? '';
log.debug('LLM response', {
model,
tokens: response.usage?.total_tokens,
});
return content;
}
/**
* Stream chat completion
*/
export async function* stream(
messages: Message[],
options: Omit<CompletionOptions, 'systemPrompt'> = {}
): AsyncGenerator<string> {
const {
model = config.llm.defaultModel,
temperature = 0.7,
maxTokens = 1000,
} = options;
const response = await client.chat.completions.create({
model,
messages,
temperature,
max_tokens: maxTokens,
stream: true,
});
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
}
/**
* {{PROJECT_NAME}}
* {{PROJECT_DESCRIPTION}}
*/
import { config } from './lib/config.js';
import { logger } from './lib/logger.js';
async function main() {
logger.info('Starting application', { env: config.env });
// Initialize your application here
// Example:
// const db = await createDatabase(config.db);
// const server = createServer({ db });
// await server.listen(config.port);
logger.info('Application started', { port: config.port });
}
main().catch((error) => {
logger.error('Failed to start application', { error: String(error) });
process.exit(1);
});
/**
* Application configuration
*
* All configuration is loaded from environment variables.
* Default values are provided for development.
*/
function getEnv(key: string, defaultValue?: string): string {
const value = process.env[key] ?? defaultValue;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
function getEnvNumber(key: string, defaultValue: number): number {
const value = process.env[key];
if (value === undefined) return defaultValue;
const num = Number(value);
if (Number.isNaN(num)) {
throw new Error(`Invalid number for environment variable: ${key}`);
}
return num;
}
function getEnvBool(key: string, defaultValue: boolean): boolean {
const value = process.env[key];
if (value === undefined) return defaultValue;
return value === 'true' || value === '1';
}
export const config = {
env: getEnv('NODE_ENV', 'development') as 'development' | 'staging' | 'production',
port: getEnvNumber('PORT', 3000),
logLevel: getEnv('LOG_LEVEL', 'info') as 'debug' | 'info' | 'warn' | 'error',
// LiteLLM proxy configuration
llm: {
baseUrl: getEnv('LITELLM_URL', 'http://localhost:4000'),
apiKey: getEnv('LITELLM_API_KEY', 'sk-1234'),
defaultModel: getEnv('LLM_MODEL', 'gpt-4o'),
},
// Database (uncomment when needed)
// db: {
// url: getEnv('DATABASE_URL'),
// poolSize: getEnvNumber('DB_POOL_SIZE', 10),
// },
// Redis (uncomment when needed)
// redis: {
// url: getEnv('REDIS_URL', 'redis://localhost:6379'),
// },
isDev: getEnv('NODE_ENV', 'development') === 'development',
isProd: getEnv('NODE_ENV', 'development') === 'production',
} as const;
export type Config = typeof config;
/**
* Custom error classes
*/
export class AppError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly statusCode: number = 500,
public readonly context?: Record<string, unknown>
) {
super(message);
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
static notFound(resource: string, id: string): AppError {
return new AppError(`${resource} not found: ${id}`, 'NOT_FOUND', 404, {
resource,
id,
});
}
static validation(message: string, context?: Record<string, unknown>): AppError {
return new AppError(message, 'VALIDATION_ERROR', 400, context);
}
static unauthorized(message = 'Unauthorized'): AppError {
return new AppError(message, 'UNAUTHORIZED', 401);
}
static forbidden(message = 'Forbidden'): AppError {
return new AppError(message, 'FORBIDDEN', 403);
}
static conflict(message: string, context?: Record<string, unknown>): AppError {
return new AppError(message, 'CONFLICT', 409, context);
}
static internal(message: string, context?: Record<string, unknown>): AppError {
return new AppError(message, 'INTERNAL_ERROR', 500, context);
}
toJSON() {
return {
name: this.name,
code: this.code,
message: this.message,
statusCode: this.statusCode,
context: this.context,
};
}
}
/**
* Type guard for AppError
*/
export function isAppError(error: unknown): error is AppError {
return error instanceof AppError;
}
/**
* Core library exports
*/
export * from './types.js';
export * from './errors.js';
export * from './config.js';
export * from './logger.js';
/**
* Structured logging
*
* Simple, structured logger for development and production.
* For more advanced logging, see structured-logging skill.
*/
import { config } from './config.js';
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
service: string;
[key: string]: unknown;
}
const LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
class Logger {
private level: LogLevel;
private service: string;
private context: Record<string, unknown> = {};
constructor(service: string, level: LogLevel = 'info') {
this.service = service;
this.level = level;
}
private shouldLog(level: LogLevel): boolean {
return LEVELS[level] >= LEVELS[this.level];
}
private write(level: LogLevel, message: string, data?: Record<string, unknown>) {
if (!this.shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
service: this.service,
...this.context,
...data,
};
const output = config.isDev
? `[${entry.timestamp}] ${level.toUpperCase().padEnd(5)} ${message} ${
data ? JSON.stringify(data) : ''
}`
: JSON.stringify(entry);
if (level === 'error') {
console.error(output);
} else {
console.log(output);
}
}
debug(message: string, data?: Record<string, unknown>) {
this.write('debug', message, data);
}
info(message: string, data?: Record<string, unknown>) {
this.write('info', message, data);
}
warn(message: string, data?: Record<string, unknown>) {
this.write('warn', message, data);
}
error(message: string, data?: Record<string, unknown>) {
this.write('error', message, data);
}
child(context: Record<string, unknown>): Logger {
const child = new Logger(this.service, this.level);
child.context = { ...this.context, ...context };
return child;
}
}
// Global logger instance
export const logger = new Logger('app', config.logLevel);
// Create child loggers for different modules
export function createLogger(module: string): Logger {
return logger.child({ module });
}
/**
* Shared type definitions
*/
/**
* Result type for operations that can fail
*/
export type Result<T, E = Error> =
| { ok: true; value: T }
| { ok: false; error: E };
export const Result = {
ok: <T>(value: T): Result<T, never> => ({ ok: true, value }),
err: <E>(error: E): Result<never, E> => ({ ok: false, error }),
map: <T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> =>
result.ok ? Result.ok(fn(result.value)) : result,
unwrap: <T, E>(result: Result<T, E>): T => {
if (!result.ok) throw result.error;
return result.value;
},
};
/**
* Branded types for type-safe IDs
*/
export type Brand<T, B> = T & { readonly __brand: B };
export type UserId = Brand<string, 'UserId'>;
export type OrderId = Brand<string, 'OrderId'>;
/**
* Utility types
*/
export type Nullable<T> = T | null;
export type Optional<T> = T | undefined;
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
/**
* Example service template
*
* Services contain business logic and orchestrate operations.
* They depend on repository/adapter interfaces, not implementations.
*/
import { z } from 'zod';
import { AppError, createLogger, type Result } from '../lib/index.js';
const log = createLogger('example-service');
// Input validation schemas
export const CreateExampleSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
});
export type CreateExampleInput = z.infer<typeof CreateExampleSchema>;
// Domain entity
export interface Example {
id: string;
name: string;
description?: string;
createdAt: Date;
}
// Repository interface (implemented by adapters)
export interface ExampleRepository {
findById(id: string): Promise<Example | null>;
findAll(): Promise<Example[]>;
save(example: Example): Promise<Example>;
delete(id: string): Promise<void>;
}
// Service implementation
export class ExampleService {
constructor(private readonly repo: ExampleRepository) {}
async create(input: CreateExampleInput): Promise<Result<Example, AppError>> {
try {
const validated = CreateExampleSchema.parse(input);
const example: Example = {
id: crypto.randomUUID(),
name: validated.name,
description: validated.description,
createdAt: new Date(),
};
const saved = await this.repo.save(example);
log.info('Example created', { id: saved.id });
return { ok: true, value: saved };
} catch (error) {
if (error instanceof z.ZodError) {
return {
ok: false,
error: AppError.validation('Invalid input', { issues: error.issues }),
};
}
throw error;
}
}
async findById(id: string): Promise<Result<Example, AppError>> {
const example = await this.repo.findById(id);
if (!example) {
return { ok: false, error: AppError.notFound('Example', id) };
}
return { ok: true, value: example };
}
async findAll(): Promise<Example[]> {
return this.repo.findAll();
}
async delete(id: string): Promise<Result<void, AppError>> {
const existing = await this.repo.findById(id);
if (!existing) {
return { ok: false, error: AppError.notFound('Example', id) };
}
await this.repo.delete(id);
log.info('Example deleted', { id });
return { ok: true, value: undefined };
}
}
/**
* Example service tests
*
* Uses real implementations (in-memory repository) instead of mocks.
* Tests actual behavior, not implementation details.
*/
import { describe, it, expect, beforeEach } from 'bun:test';
import { ExampleService } from '../src/services/example.service.js';
import { InMemoryExampleRepository } from '../src/adapters/in-memory.repository.js';
describe('ExampleService', () => {
let service: ExampleService;
let repo: InMemoryExampleRepository;
beforeEach(() => {
repo = new InMemoryExampleRepository();
service = new ExampleService(repo);
});
describe('create', () => {
it('creates example with valid input', async () => {
const result = await service.create({
name: 'Test Example',
description: 'A test description',
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.name).toBe('Test Example');
expect(result.value.description).toBe('A test description');
expect(result.value.id).toBeDefined();
expect(result.value.createdAt).toBeInstanceOf(Date);
}
});
it('rejects empty name', async () => {
const result = await service.create({
name: '',
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('VALIDATION_ERROR');
}
});
it('persists to repository', async () => {
const result = await service.create({ name: 'Persisted' });
expect(result.ok).toBe(true);
if (result.ok) {
const found = await repo.findById(result.value.id);
expect(found).toEqual(result.value);
}
});
});
describe('findById', () => {
it('returns example when found', async () => {
const created = await service.create({ name: 'Find Me' });
if (!created.ok) throw new Error('Setup failed');
const result = await service.findById(created.value.id);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.name).toBe('Find Me');
}
});
it('returns error when not found', async () => {
const result = await service.findById('non-existent-id');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('NOT_FOUND');
}
});
});
describe('delete', () => {
it('deletes existing example', async () => {
const created = await service.create({ name: 'Delete Me' });
if (!created.ok) throw new Error('Setup failed');
const result = await service.delete(created.value.id);
expect(result.ok).toBe(true);
expect(await repo.findById(created.value.id)).toBeNull();
});
it('returns error for non-existent example', async () => {
const result = await service.delete('non-existent-id');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.code).toBe('NOT_FOUND');
}
});
});
describe('findAll', () => {
it('returns all examples', async () => {
await service.create({ name: 'First' });
await service.create({ name: 'Second' });
await service.create({ name: 'Third' });
const all = await service.findAll();
expect(all).toHaveLength(3);
expect(all.map((e) => e.name)).toContain('First');
expect(all.map((e) => e.name)).toContain('Second');
expect(all.map((e) => e.name)).toContain('Third');
});
it('returns empty array when no examples', async () => {
const all = await service.findAll();
expect(all).toHaveLength(0);
});
});
});
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}