dotmd

TypeScript Project — Copilot Instructions

GitHub Copilot instructions for TypeScript repositories with strict typing and practical architecture patterns.

By dotmd TeamCC0Published Feb 19, 2026View source ↗

Install path

Use this file for each supported tool in your project.

  • GitHub Copilot: Save as copilot-instructions.md in your project at .github/copilot-instructions.md.

Configuration

copilot-instructions.md

1# TypeScript Project — Copilot Instructions
2
3TypeScript project with strict mode enabled. Prioritize type safety, explicit error handling, and testable code. Never use `any` — use `unknown` and narrow instead.
4
5## Quick Reference
6
7| 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 |
18
19## Project Structure
20
21```
22├── src/
23│ ├── index.ts # Entry point / public API
24│ ├── types/ # Shared type definitions
25│ │ └── index.ts
26│ ├── utils/ # Pure utility functions
27│ ├── errors/ # Custom error classes
28│ ├── services/ # Business logic modules
29│ └── [domain]/ # Feature-scoped modules
30├── tests/ # Test files (or colocated as *.test.ts)
31├── tsconfig.json
32├── package.json
33└── vitest.config.ts # or jest.config.ts
34```
35
36## Type Conventions
37
38### Prefer `interface` for Object Shapes, `type` for Unions and Utilities
39
40```typescript
41// ✅ interface for objects — can be extended, better error messages
42interface User {
43 id: string;
44 email: string;
45 role: UserRole;
46 createdAt: Date;
47}
48
49// ✅ type for unions, intersections, mapped types
50type 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```
54
55### Use `satisfies` to Check Types Without Widening
56
57```typescript
58// ✅ Type-checked but preserves literal types
59const 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`
65
66// ❌ Don't use `as` to silence the compiler
67const user = response.data as User; // Hides real type mismatches
68```
69
70### Narrow `unknown` Instead of Using `any`
71
72```typescript
73// ✅ Proper narrowing
74function parseJson(raw: unknown): User {
75 if (
76 typeof raw === "object" &&
77 raw !== null &&
78 "id" in raw &&
79 "email" in raw
80 ) {
81 return raw as User; // Safe — we verified the shape
82 }
83 throw new InvalidInputError("Invalid user data");
84}
85
86// ✅ Use Zod for runtime validation (preferred)
87import { z } from "zod";
88
89const UserSchema = z.object({
90 id: z.string().uuid(),
91 email: z.string().email(),
92 role: z.enum(["admin", "member", "viewer"]),
93});
94
95type User = z.infer<typeof UserSchema>;
96
97function validateUser(data: unknown): User {
98 return UserSchema.parse(data);
99}
100```
101
102### Function Signatures
103
104- 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.
107
108```typescript
109// ✅ Explicit return type on exported function
110export function findUser(
111 users: readonly User[],
112 predicate: (user: User) => boolean
113): User | undefined {
114 return users.find(predicate);
115}
116
117// ✅ Inferred return type is fine for simple local helpers
118const formatName = (first: string, last: string) => `${first} ${last}`;
119```
120
121## Error Handling
122
123### Define Typed Errors — Never Throw Raw Strings
124
125```typescript
126// src/errors/base.ts
127export class AppError extends Error {
128 constructor(
129 message: string,
130 public readonly code: string,
131 public readonly statusCode: number = 500,
132 public readonly cause?: Error
133 ) {
134 super(message);
135 this.name = this.constructor.name;
136 }
137}
138
139export class NotFoundError extends AppError {
140 constructor(resource: string, id: string, cause?: Error) {
141 super(`${resource} '${id}' not found`, "NOT_FOUND", 404, cause);
142 }
143}
144
145export 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}
153
154export class ConflictError extends AppError {
155 constructor(message: string, cause?: Error) {
156 super(message, "CONFLICT", 409, cause);
157 }
158}
159```
160
161### Use Result Types for Operations That Can Fail Predictably
162
163```typescript
164type Result<T, E = AppError> =
165 | { ok: true; value: T }
166 | { ok: false; error: E };
167
168// ✅ Explicit success/failure — caller must handle both
169async function createUser(
170 input: CreateUserInput
171): 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 }
179
180 const existing = await db.findByEmail(parsed.data.email);
181 if (existing) {
182 return { ok: false, error: new ConflictError("Email already registered") };
183 }
184
185 const user = await db.insert(parsed.data);
186 return { ok: true, value: user };
187}
188
189// Caller
190const result = await createUser(input);
191if (!result.ok) {
192 // TypeScript knows result.error is ValidationError | ConflictError
193 return res.status(result.error.statusCode).json({ error: result.error.message });
194}
195// TypeScript knows result.value is User
196console.log(result.value.id);
197```
198
199## Validation Patterns
200
201### Zod Schemas as Single Source of Truth
202
203```typescript
204import { z } from "zod";
205
206// Define the schema once, derive the type
207export 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});
212
213export type CreateUserInput = z.input<typeof CreateUserSchema>; // What the caller sends
214export type CreateUserData = z.output<typeof CreateUserSchema>; // What you get after parsing
215
216// Reuse schemas with transformations
217export const UpdateUserSchema = CreateUserSchema.partial().omit({ role: true });
218```
219
220### Validate at Boundaries, Trust Internally
221
222Validate 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.
223
224## Async Patterns
225
226### Always Handle Promise Rejections
227
228```typescript
229// ✅ Wrap concurrent operations properly
230async function fetchAllUsers(ids: string[]): Promise<User[]> {
231 const results = await Promise.allSettled(
232 ids.map((id) => fetchUser(id))
233 );
234
235 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}
245
246// ❌ Don't use bare Promise.all when individual failures are recoverable
247// Promise.all rejects on the FIRST failure — you lose all results
248```
249
250### Async Iterables for Streaming Data
251
252```typescript
253// ✅ Process large datasets without loading everything into memory
254async 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```
261
262## Testing
263
264### Test Structure — Arrange / Act / Assert
265
266```typescript
267import { describe, it, expect, vi } from "vitest";
268import { createUser } from "./user-service";
269
270describe("createUser", () => {
271 it("creates a user with valid input", async () => {
272 // Arrange
273 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" };
276
277 // Act
278 const result = await service.create(input);
279
280 // Assert
281 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 });
289
290 it("returns validation error for invalid email", async () => {
291 const service = new UserService(mockDb);
292
293 const result = await service.create({ email: "not-an-email", name: "Alice" });
294
295 expect(result.ok).toBe(false);
296 if (!result.ok) {
297 expect(result.error.code).toBe("VALIDATION_ERROR");
298 }
299 });
300
301 it("returns conflict error for duplicate email", async () => {
302 const mockDb = { findByEmail: vi.fn().mockResolvedValue({ id: "1" }) };
303 const service = new UserService(mockDb);
304
305 const result = await service.create({ email: "taken@b.com", name: "Bob" });
306
307 expect(result.ok).toBe(false);
308 if (!result.ok) {
309 expect(result.error).toBeInstanceOf(ConflictError);
310 }
311 });
312});
313```
314
315### Test Pure Functions With Parameterized Cases
316
317```typescript
318import { describe, it, expect } from "vitest";
319import { slugify } from "./string-utils";
320
321describe("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```
334
335## Common Patterns
336
337### Discriminated Unions for State Machines
338
339```typescript
340type AsyncState<T> =
341 | { status: "idle" }
342 | { status: "loading" }
343 | { status: "success"; data: T }
344 | { status: "error"; error: AppError };
345
346function 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` exists
354 case "error":
355 return `Error: ${state.error.message}`; // TypeScript knows `error` exists
356 }
357}
358```
359
360## Import Conventions
361
362```typescript
363// 1. Node.js built-in modules
364import { readFile } from "node:fs/promises";
365import { join } from "node:path";
366
367// 2. External dependencies
368import { z } from "zod";
369import { describe, it, expect } from "vitest";
370
371// 3. Internal modules (path alias or relative)
372import { AppError } from "@/errors/base";
373import { UserService } from "@/services/user";
374import type { User, UserRole } from "@/types";
375
376// ✅ Use `import type` for type-only imports — helps bundlers tree-shake
377import type { Request, Response } from "express";
378```
379
380## Code Generation Guidelines
381
3821. **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: