TypeScript Project — Copilot Instructions
GitHub Copilot instructions for TypeScript repositories with strict typing and practical architecture patterns.
Install path
Use this file for each supported tool in your project.
- GitHub Copilot: Save as
copilot-instructions.mdin your project at.github/copilot-instructions.md.
Configuration
copilot-instructions.md
1# TypeScript Project — Copilot Instructions23TypeScript project with strict mode enabled. Prioritize type safety, explicit error handling, and testable code. Never use `any` — use `unknown` and narrow instead.45## Quick Reference67| Area | Convention |8|---|---|9| Language | TypeScript 5.x, strict mode (`"strict": true`) |10| Runtime | Node.js 20+ or browser (project-dependent) |11| Package manager | Check lockfile: `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm |12| Module system | ESM (`"type": "module"` in package.json) |13| Linting | ESLint with `@typescript-eslint` — follow existing `.eslintrc` |14| Formatting | Prettier — follow existing `.prettierrc` |15| Testing | Vitest (preferred) or Jest — check `package.json` scripts |16| Imports | Use path aliases if configured (`@/`, `~/`); otherwise relative |17| Build | Check for `tsconfig.build.json` — some projects separate build from dev config |1819## Project Structure2021```22├── src/23│ ├── index.ts # Entry point / public API24│ ├── types/ # Shared type definitions25│ │ └── index.ts26│ ├── utils/ # Pure utility functions27│ ├── errors/ # Custom error classes28│ ├── services/ # Business logic modules29│ └── [domain]/ # Feature-scoped modules30├── tests/ # Test files (or colocated as *.test.ts)31├── tsconfig.json32├── package.json33└── vitest.config.ts # or jest.config.ts34```3536## Type Conventions3738### Prefer `interface` for Object Shapes, `type` for Unions and Utilities3940```typescript41// ✅ interface for objects — can be extended, better error messages42interface User {43 id: string;44 email: string;45 role: UserRole;46 createdAt: Date;47}4849// ✅ type for unions, intersections, mapped types50type UserRole = "admin" | "member" | "viewer";51type WithTimestamps<T> = T & { createdAt: Date; updatedAt: Date };52type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;53```5455### Use `satisfies` to Check Types Without Widening5657```typescript58// ✅ Type-checked but preserves literal types59const config = {60 port: 3000,61 host: "localhost",62 debug: false,63} satisfies Record<string, string | number | boolean>;64// config.port is still `number`, not `string | number | boolean`6566// ❌ Don't use `as` to silence the compiler67const user = response.data as User; // Hides real type mismatches68```6970### Narrow `unknown` Instead of Using `any`7172```typescript73// ✅ Proper narrowing74function parseJson(raw: unknown): User {75 if (76 typeof raw === "object" &&77 raw !== null &&78 "id" in raw &&79 "email" in raw80 ) {81 return raw as User; // Safe — we verified the shape82 }83 throw new InvalidInputError("Invalid user data");84}8586// ✅ Use Zod for runtime validation (preferred)87import { z } from "zod";8889const UserSchema = z.object({90 id: z.string().uuid(),91 email: z.string().email(),92 role: z.enum(["admin", "member", "viewer"]),93});9495type User = z.infer<typeof UserSchema>;9697function validateUser(data: unknown): User {98 return UserSchema.parse(data);99}100```101102### Function Signatures103104- Annotate return types on exported functions and anything non-trivial.105- Let TypeScript infer return types for simple private/local functions.106- Use `readonly` for parameters that should not be mutated.107108```typescript109// ✅ Explicit return type on exported function110export function findUser(111 users: readonly User[],112 predicate: (user: User) => boolean113): User | undefined {114 return users.find(predicate);115}116117// ✅ Inferred return type is fine for simple local helpers118const formatName = (first: string, last: string) => `${first} ${last}`;119```120121## Error Handling122123### Define Typed Errors — Never Throw Raw Strings124125```typescript126// src/errors/base.ts127export class AppError extends Error {128 constructor(129 message: string,130 public readonly code: string,131 public readonly statusCode: number = 500,132 public readonly cause?: Error133 ) {134 super(message);135 this.name = this.constructor.name;136 }137}138139export class NotFoundError extends AppError {140 constructor(resource: string, id: string, cause?: Error) {141 super(`${resource} '${id}' not found`, "NOT_FOUND", 404, cause);142 }143}144145export class ValidationError extends AppError {146 constructor(147 message: string,148 public readonly fields: Record<string, string>149 ) {150 super(message, "VALIDATION_ERROR", 400);151 }152}153154export class ConflictError extends AppError {155 constructor(message: string, cause?: Error) {156 super(message, "CONFLICT", 409, cause);157 }158}159```160161### Use Result Types for Operations That Can Fail Predictably162163```typescript164type Result<T, E = AppError> =165 | { ok: true; value: T }166 | { ok: false; error: E };167168// ✅ Explicit success/failure — caller must handle both169async function createUser(170 input: CreateUserInput171): Promise<Result<User, ValidationError | ConflictError>> {172 const parsed = CreateUserSchema.safeParse(input);173 if (!parsed.success) {174 return {175 ok: false,176 error: new ValidationError("Invalid input", formatZodErrors(parsed.error)),177 };178 }179180 const existing = await db.findByEmail(parsed.data.email);181 if (existing) {182 return { ok: false, error: new ConflictError("Email already registered") };183 }184185 const user = await db.insert(parsed.data);186 return { ok: true, value: user };187}188189// Caller190const result = await createUser(input);191if (!result.ok) {192 // TypeScript knows result.error is ValidationError | ConflictError193 return res.status(result.error.statusCode).json({ error: result.error.message });194}195// TypeScript knows result.value is User196console.log(result.value.id);197```198199## Validation Patterns200201### Zod Schemas as Single Source of Truth202203```typescript204import { z } from "zod";205206// Define the schema once, derive the type207export const CreateUserSchema = z.object({208 email: z.string().email(),209 name: z.string().min(1).max(200),210 role: z.enum(["admin", "member", "viewer"]).default("member"),211});212213export type CreateUserInput = z.input<typeof CreateUserSchema>; // What the caller sends214export type CreateUserData = z.output<typeof CreateUserSchema>; // What you get after parsing215216// Reuse schemas with transformations217export const UpdateUserSchema = CreateUserSchema.partial().omit({ role: true });218```219220### Validate at Boundaries, Trust Internally221222Validate external input once at the entry point (API handler, CLI parser, event consumer). Internal function calls between modules trust the types — no redundant re-validation.223224## Async Patterns225226### Always Handle Promise Rejections227228```typescript229// ✅ Wrap concurrent operations properly230async function fetchAllUsers(ids: string[]): Promise<User[]> {231 const results = await Promise.allSettled(232 ids.map((id) => fetchUser(id))233 );234235 const users: User[] = [];236 for (const result of results) {237 if (result.status === "fulfilled") {238 users.push(result.value);239 } else {240 logger.warn("Failed to fetch user", { error: result.reason });241 }242 }243 return users;244}245246// ❌ Don't use bare Promise.all when individual failures are recoverable247// Promise.all rejects on the FIRST failure — you lose all results248```249250### Async Iterables for Streaming Data251252```typescript253// ✅ Process large datasets without loading everything into memory254async function* readCsvRows(path: string): AsyncGenerator<Record<string, string>> {255 const stream = createReadStream(path).pipe(csvParser());256 for await (const row of stream) {257 yield row;258 }259}260```261262## Testing263264### Test Structure — Arrange / Act / Assert265266```typescript267import { describe, it, expect, vi } from "vitest";268import { createUser } from "./user-service";269270describe("createUser", () => {271 it("creates a user with valid input", async () => {272 // Arrange273 const mockDb = { insert: vi.fn().mockResolvedValue({ id: "1", email: "a@b.com" }) };274 const service = new UserService(mockDb);275 const input = { email: "a@b.com", name: "Alice" };276277 // Act278 const result = await service.create(input);279280 // Assert281 expect(result.ok).toBe(true);282 if (result.ok) {283 expect(result.value.email).toBe("a@b.com");284 }285 expect(mockDb.insert).toHaveBeenCalledWith(286 expect.objectContaining({ email: "a@b.com" })287 );288 });289290 it("returns validation error for invalid email", async () => {291 const service = new UserService(mockDb);292293 const result = await service.create({ email: "not-an-email", name: "Alice" });294295 expect(result.ok).toBe(false);296 if (!result.ok) {297 expect(result.error.code).toBe("VALIDATION_ERROR");298 }299 });300301 it("returns conflict error for duplicate email", async () => {302 const mockDb = { findByEmail: vi.fn().mockResolvedValue({ id: "1" }) };303 const service = new UserService(mockDb);304305 const result = await service.create({ email: "taken@b.com", name: "Bob" });306307 expect(result.ok).toBe(false);308 if (!result.ok) {309 expect(result.error).toBeInstanceOf(ConflictError);310 }311 });312});313```314315### Test Pure Functions With Parameterized Cases316317```typescript318import { describe, it, expect } from "vitest";319import { slugify } from "./string-utils";320321describe("slugify", () => {322 it.each([323 ["Hello World", "hello-world"],324 [" Extra Spaces ", "extra-spaces"],325 ["Special @#$ Characters!", "special-characters"],326 ["Already-Slugged", "already-slugged"],327 ["UPPERCASE", "uppercase"],328 ["", ""],329 ])("slugify(%j) → %j", (input, expected) => {330 expect(slugify(input)).toBe(expected);331 });332});333```334335## Common Patterns336337### Discriminated Unions for State Machines338339```typescript340type AsyncState<T> =341 | { status: "idle" }342 | { status: "loading" }343 | { status: "success"; data: T }344 | { status: "error"; error: AppError };345346function renderState<T>(state: AsyncState<T>): string {347 switch (state.status) {348 case "idle":349 return "Ready";350 case "loading":351 return "Loading...";352 case "success":353 return `Got ${JSON.stringify(state.data)}`; // TypeScript knows `data` exists354 case "error":355 return `Error: ${state.error.message}`; // TypeScript knows `error` exists356 }357}358```359360## Import Conventions361362```typescript363// 1. Node.js built-in modules364import { readFile } from "node:fs/promises";365import { join } from "node:path";366367// 2. External dependencies368import { z } from "zod";369import { describe, it, expect } from "vitest";370371// 3. Internal modules (path alias or relative)372import { AppError } from "@/errors/base";373import { UserService } from "@/services/user";374import type { User, UserRole } from "@/types";375376// ✅ Use `import type` for type-only imports — helps bundlers tree-shake377import type { Request, Response } from "express";378```379380## Code Generation Guidelines3813821. **Match existing project patterns.** If the codebase uses classes, use classes. If it uses plain functions and modules, do that. Consistency wins.3832. **Don't add dependencies without justification.** Suggest them — don't install them.3843. **Prefer the standard library.** Node.js `crypto.randomUUID()` over `uuid`. `URL` constructor over regex URL parsing. `structuredClone` over lodash `cloneDeep`.3854. **Keep functions small and focused.** If a function does more than one thing, split it. A 20-line function is almost always better than a 60-line one.3865. **Name for readability.** `getUserById` over `getUser`. `isExpired` over `checkExpiry`. Boolean variables/functions start with `is`, `has`, `should`, `can`.3876. **Comments explain WHY, not WHAT.** The code shows what it does. Comments explain non-obvious business logic, workarounds, or performance trade-offs.388
Community feedback
0 found this helpful
Works with: