TypeScript Guidelines
Write type-safe, ergonomic TypeScript. Prioritize correctness and developer experience.
TSConfig Requirements
Always ensure these compiler options are enabled:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"strictNullChecks": true
}
}
Core Principles
Avoid any
Everything should be typed. If you need escape hatches:
// Bad
const data: any = fetchData();
// Better - use unknown and narrow
const data: unknown = fetchData();
if (isUser(data)) {
console.log(data.name);
}
// Or use generics
function fetchData<T>(): T { ... }
Prefer Existing Types
Reuse types from libraries and codebase. Use Pick/Omit for ergonomics:
// Bad - duplicating types
interface CreateUserInput {
name: string;
email: string;
}
// Good - derive from existing
type CreateUserInput = Pick<User, 'name' | 'email'>;
// Good - omit what you don't need
type UserWithoutId = Omit<User, 'id' | 'createdAt'>;
// Good - for function params
function updateUser(id: string, data: Partial<Pick<User, 'name' | 'email'>>) { ... }
Prefer Interfaces Over Types
// Preferred
interface User {
id: string;
name: string;
}
// Use type for unions, intersections, mapped types
type Status = 'pending' | 'active' | 'inactive';
type UserWithRole = User & { role: Role };
Let TypeScript Infer When Trivial
// Unnecessary - TypeScript infers this const name: string = 'John'; const count: number = 0; const users: User[] = []; // Better - let inference work const name = 'John'; const count = 0; const users: User[] = []; // Keep when empty array needs type
Avoid Type Casting When Possible
// Bad - casting
const user = data as User;
// Better - use generics
const user = fetchData<User>();
// Better - type the variable
const user: User = { id: '1', name: 'John' };
// Better - use type guards
if (isUser(data)) {
// data is User here
}
Pragmatic Typing for Isolated Scopes
When a type is complex and isolated, focus on input/output ergonomics:
// Input and output are well-typed, internal casting is acceptable
function transformData(input: RawData): ProcessedData {
const intermediate = input.items.map(item => {
// Complex transformation - casting ok here if isolated
return processItem(item) as ProcessedItem;
});
return { items: intermediate };
}
Type Narrowing
Use TypeScript's narrowing capabilities. Reference: https://www.typescriptlang.org/docs/handbook/2/narrowing.html
Discriminated Unions
interface LoadingState {
status: 'loading';
}
interface SuccessState {
status: 'success';
data: User[];
}
interface ErrorState {
status: 'error';
error: Error;
}
type State = LoadingState | SuccessState | ErrorState;
function render(state: State) {
switch (state.status) {
case 'loading':
return <Spinner />;
case 'success':
return <UserList users={state.data} />;
case 'error':
return <Error message={state.error.message} />;
}
}
instanceof
function handleError(error: unknown) {
if (error instanceof ValidationError) {
return { field: error.field, message: error.message };
}
if (error instanceof Error) {
return { message: error.message };
}
return { message: 'Unknown error' };
}
in Operator
interface Dog { bark(): void; }
interface Cat { meow(): void; }
function speak(animal: Dog | Cat) {
if ('bark' in animal) {
animal.bark();
} else {
animal.meow();
}
}
Type Predicates
Essential for filter functions:
// Type predicate
function isNonNull<T>(value: T | null | undefined): value is T {
return value != null;
}
// Usage with filter
const users: (User | null)[] = [...];
const validUsers: User[] = users.filter(isNonNull);
// Without predicate, TypeScript doesn't narrow
const broken = users.filter(u => u != null); // Still (User | null)[]
Avoid Meaningless Try/Catch
Don't wrap code in try/catch unless you're actually handling the error meaningfully:
// Bad - catching just to rethrow or log
try {
await saveUser(user);
} catch (error) {
console.error(error);
throw error;
}
// Bad - swallowing errors silently
try {
await saveUser(user);
} catch {
// silent failure
}
// Good - let errors propagate naturally
await saveUser(user);
// Good - actual error handling with recovery or transformation
try {
await saveUser(user);
} catch (error) {
if (error instanceof DuplicateEmailError) {
return { success: false, message: 'Email already exists' };
}
throw error; // rethrow unknown errors
}
// Good - cleanup with finally (but consider using `using` instead)
const connection = await getConnection();
try {
await connection.query(sql);
} finally {
await connection.release();
}
Only use try/catch when you:
- •Transform the error into a user-facing result
- •Recover from specific error types
- •Need cleanup logic (prefer
usingkeyword when available) - •Are at a boundary where you must not let errors escape
Functional Programming
Prefer functional patterns:
// Bad - imperative loop
const results: string[] = [];
for (const user of users) {
if (user.active) {
results.push(user.name);
}
}
// Good - functional
const results = users
.filter(user => user.active)
.map(user => user.name);
// Good - with type predicate
const activeNames = users
.filter((user): user is ActiveUser => user.active)
.map(user => user.name);
Task Completion Requirements
A task is NOT complete until all type errors are resolved in modified files.
Before marking any task as done:
- •Run
bun run typecheck(ortsc --noEmit) - •Fix ALL type errors in files you modified
- •Verify the typecheck passes cleanly
If the typecheck hook reports errors after your edits, you must fix them before proceeding to the next task.
Quick Reference
| Do | Don't |
|---|---|
interface User | type User = { ... } (for objects) |
Pick<User, 'name'> | Duplicate type definitions |
const x = 5 | const x: number = 5 |
fetchData<User>() | fetchData() as User |
.filter(isNonNull) | .filter(x => x != null) without predicate |
.map().filter() | for loops for transformations |