Sub-Skill: TypeScript Best Practices
Purpose: Prevents the TypeScript-specific mistakes LLMs make repeatedly — weak types, unsafe assertions, and patterns that compile but fail at runtime.
Rule classification
- MUST — load-bearing. Violating leaks runtime errors past the type checker. Never break.
- SHOULD — default behavior. Deviation needs a documented reason in the code or PR.
- AVOID — usually wrong; documented exception inline where needed.
Where these rules don't strictly apply: test fixtures, generated types (e.g. from GraphQL codegen, OpenAPI generators, Prisma), declaration files (*.d.ts) for untyped third-party libraries, and migration scripts may legitimately differ. The rules below apply to production application code.
Type Safety
MUST: Never use any. Use unknown and narrow it. any disables the type checker entirely. unknown forces you to prove the type before use. Exception: third-party libraries without types and explicit dynamic-data boundaries (e.g. JSON parse at the API edge), with a comment explaining why.
// Wrong
function parse(data: any) { return data.name; }
// Correct
function parse(data: unknown): string {
if (typeof data === 'object' && data !== null && 'name' in data) {
return String((data as { name: unknown }).name);
}
throw new Error('Invalid data shape');
}
AVOID: Object or {} as a type. Both accept nearly everything. Use Record<string, unknown> for arbitrary objects or define an explicit interface.
// Wrong
function merge(a: {}, b: Object): {} { ... }
// Correct
function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T { ... }
SHOULD: Use as only when you know more than the compiler — and document why. Prefer type guards or satisfies instead.
// Wrong — silences the error, hides the bug
const user = response.data as User;
// Correct — validate first
function isUser(v: unknown): v is User {
return typeof v === 'object' && v !== null && 'id' in v && 'email' in v;
}
const user = isUser(response.data) ? response.data : null;
SHOULD: Mark immutable data readonly. Prevents accidental mutation and communicates intent.
// Avoid
function process(ids: string[]) { ids.push('extra'); }
// Prefer
function process(ids: readonly string[]) { /* ids.push() is a compile error */ }
MUST: Enable strictNullChecks and handle every T | undefined. Optional chaining ?. returns undefined — always handle that branch.
// Wrong
const name = user?.profile.name.toUpperCase(); // crashes if name is undefined
// Correct
const name = user?.profile.name?.toUpperCase() ?? 'Anonymous';
SHOULD: Use branded types for IDs. Prevents passing a UserId where an OrderId is expected — both are string at runtime.
type UserId = string & { readonly _brand: 'UserId' };
type OrderId = string & { readonly _brand: 'OrderId' };
function createUserId(raw: string): UserId { return raw as UserId; }
function getUser(id: UserId): User { ... }
// getUser(orderId) → compile error
Patterns
SHOULD: Use discriminated unions for state, not optional fields. Optional fields force you to reason about all combinations. A discriminated union makes illegal states unrepresentable.
// Avoid — 8 possible combinations, most invalid
type Request = { loading?: boolean; data?: User; error?: Error };
// Prefer — exactly 3 valid states
type Request =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: User }
| { status: 'error'; error: Error };
SHOULD: Use satisfies to validate shape without widening the type. as const preserves literals; satisfies validates against an interface without losing them.
const config = {
host: 'localhost',
port: 5432,
} satisfies DatabaseConfig;
// config.port is still typed as 5432, not number
SHOULD: Use const objects instead of enum. Enums emit runtime code, have surprising reverse-mapping behavior, and are not idiomatic TypeScript.
// Avoid
enum Direction { Up, Down, Left, Right }
// Prefer
const Direction = { Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right' } as const;
type Direction = typeof Direction[keyof typeof Direction];
AVOID: Barrel index.ts re-exports in large modules. They cause circular dependency chains that are hard to debug. Export directly from source files or use explicit named re-exports only.
// Wrong — index.ts re-exports everything, A imports B through index, B imports A through index
export * from './userService';
export * from './orderService';
// Correct — import directly
import { getUser } from './services/userService';
Error Handling
MUST: Type your thrown errors explicitly. catch (e) gives you unknown in strict mode. Narrow before accessing properties.
try {
await fetchUser(id);
} catch (e) {
// Wrong: e.message — e is unknown
// Correct:
const message = e instanceof Error ? e.message : String(e);
logger.error('fetchUser failed', { message, id });
}
SHOULD: Use Result types for expected failures instead of throwing. Throwing for control flow forces callers to know which functions throw and what.
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
function parseConfig(raw: string): Result<Config> {
try {
return { ok: true, value: JSON.parse(raw) };
} catch (e) {
return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };
}
}
Performance
MUST: Use Promise.all for independent async operations, not sequential await. Sequential awaits multiply latency.
// Wrong — 300ms total if each takes 100ms
const user = await getUser(id);
const orders = await getOrders(id);
const prefs = await getPrefs(id);
// Correct — 100ms total
const [user, orders, prefs] = await Promise.all([
getUser(id),
getOrders(id),
getPrefs(id),
]);
SHOULD: Use Promise.allSettled when partial failure is acceptable. Promise.all rejects on the first failure. allSettled collects all results.
const results = await Promise.allSettled(ids.map(fetchItem));
const succeeded = results
.filter((r): r is PromiseFulfilledResult<Item> => r.status === 'fulfilled')
.map(r => r.value);
Testing
MUST: Type test helpers and mocks — never use as any to silence mock errors. Untyped mocks let type errors hide until runtime. Exception: prototyping spikes that are explicitly thrown away before merge.
// Wrong
const mockUser = { id: '1' } as any;
// Correct
const mockUser: User = { id: createUserId('1'), email: 'a@b.com', name: 'Alice' };
SHOULD: Test the discriminated union branches explicitly. Each status variant is a separate code path. One test per branch minimum.
it('renders error state', () => {
const state: Request = { status: 'error', error: new Error('timeout') };
render(<RequestView state={state} />);
expect(screen.getByRole('alert')).toHaveTextContent('timeout');
});
SHOULD: Use expectTypeOf or assertType for type-level tests. Runtime tests cannot catch type regressions.
import { expectTypeOf } from 'vitest';
expectTypeOf(createUserId('x')).toEqualTypeOf<UserId>();
Why This Sub-Skill Earns Stars
These rules target the gap between "TypeScript that compiles" and "TypeScript that is safe". LLMs default to any, skip discriminated unions, and reach for as assertions because they are the path of least resistance. Each rule here closes a specific escape hatch that lets type errors reach production. The MUST/SHOULD/AVOID classification means safety-critical rules are strict and stylistic rules respect context.
1---2name: typescript3description: Apply when writing TypeScript code. Strict types, discriminated unions, async patterns, and runtime safety.4license: MIT5---67# Sub-Skill: TypeScript Best Practices8<!-- target: ~2500 tokens (real tiktoken count) | 17 rules with severity classification -->910**Purpose:** Prevents the TypeScript-specific mistakes LLMs make repeatedly — weak types, unsafe assertions, and patterns that compile but fail at runtime.1112## Rule classification1314- **MUST** — load-bearing. Violating leaks runtime errors past the type checker. Never break.15- **SHOULD** — default behavior. Deviation needs a documented reason in the code or PR.16- **AVOID** — usually wrong; documented exception inline where needed.1718**Where these rules don't strictly apply:** test fixtures, generated types (e.g. from GraphQL codegen, OpenAPI generators, Prisma), declaration files (`*.d.ts`) for untyped third-party libraries, and migration scripts may legitimately differ. The rules below apply to **production application code**.1920---2122## Type Safety23241. **MUST: Never use `any`. Use `unknown` and narrow it.** `any` disables the type checker entirely. `unknown` forces you to prove the type before use. *Exception: third-party libraries without types and explicit dynamic-data boundaries (e.g. JSON parse at the API edge), with a comment explaining why.*25 ```ts26 // Wrong27 function parse(data: any) { return data.name; }2829 // Correct30 function parse(data: unknown): string {31 if (typeof data === 'object' && data !== null && 'name' in data) {32 return String((data as { name: unknown }).name);33 }34 throw new Error('Invalid data shape');35 }36 ```37382. **AVOID: `Object` or `{}` as a type.** Both accept nearly everything. Use `Record<string, unknown>` for arbitrary objects or define an explicit interface.39 ```ts40 // Wrong41 function merge(a: {}, b: Object): {} { ... }4243 // Correct44 function merge<T extends Record<string, unknown>>(a: T, b: Partial<T>): T { ... }45 ```46473. **SHOULD: Use `as` only when you know more than the compiler — and document why.** Prefer type guards or `satisfies` instead.48 ```ts49 // Wrong — silences the error, hides the bug50 const user = response.data as User;5152 // Correct — validate first53 function isUser(v: unknown): v is User {54 return typeof v === 'object' && v !== null && 'id' in v && 'email' in v;55 }56 const user = isUser(response.data) ? response.data : null;57 ```58594. **SHOULD: Mark immutable data `readonly`.** Prevents accidental mutation and communicates intent.60 ```ts61 // Avoid62 function process(ids: string[]) { ids.push('extra'); }6364 // Prefer65 function process(ids: readonly string[]) { /* ids.push() is a compile error */ }66 ```67685. **MUST: Enable `strictNullChecks` and handle every `T | undefined`.** Optional chaining `?.` returns `undefined` — always handle that branch.69 ```ts70 // Wrong71 const name = user?.profile.name.toUpperCase(); // crashes if name is undefined7273 // Correct74 const name = user?.profile.name?.toUpperCase() ?? 'Anonymous';75 ```76776. **SHOULD: Use branded types for IDs.** Prevents passing a `UserId` where an `OrderId` is expected — both are `string` at runtime.78 ```ts79 type UserId = string & { readonly _brand: 'UserId' };80 type OrderId = string & { readonly _brand: 'OrderId' };8182 function createUserId(raw: string): UserId { return raw as UserId; }8384 function getUser(id: UserId): User { ... }85 // getUser(orderId) → compile error86 ```8788---8990## Patterns91927. **SHOULD: Use discriminated unions for state, not optional fields.** Optional fields force you to reason about all combinations. A discriminated union makes illegal states unrepresentable.93 ```ts94 // Avoid — 8 possible combinations, most invalid95 type Request = { loading?: boolean; data?: User; error?: Error };9697 // Prefer — exactly 3 valid states98 type Request =99 | { status: 'idle' }100 | { status: 'loading' }101 | { status: 'success'; data: User }102 | { status: 'error'; error: Error };103 ```1041058. **SHOULD: Use `satisfies` to validate shape without widening the type.** `as const` preserves literals; `satisfies` validates against an interface without losing them.106 ```ts107 const config = {108 host: 'localhost',109 port: 5432,110 } satisfies DatabaseConfig;111 // config.port is still typed as 5432, not number112 ```1131149. **SHOULD: Use `const` objects instead of `enum`.** Enums emit runtime code, have surprising reverse-mapping behavior, and are not idiomatic TypeScript.115 ```ts116 // Avoid117 enum Direction { Up, Down, Left, Right }118119 // Prefer120 const Direction = { Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right' } as const;121 type Direction = typeof Direction[keyof typeof Direction];122 ```12312410. **AVOID: Barrel `index.ts` re-exports in large modules.** They cause circular dependency chains that are hard to debug. Export directly from source files or use explicit named re-exports only.125 ```ts126 // Wrong — index.ts re-exports everything, A imports B through index, B imports A through index127 export * from './userService';128 export * from './orderService';129130 // Correct — import directly131 import { getUser } from './services/userService';132 ```133134---135136## Error Handling13713811. **MUST: Type your thrown errors explicitly.** `catch (e)` gives you `unknown` in strict mode. Narrow before accessing properties.139 ```ts140 try {141 await fetchUser(id);142 } catch (e) {143 // Wrong: e.message — e is unknown144 // Correct:145 const message = e instanceof Error ? e.message : String(e);146 logger.error('fetchUser failed', { message, id });147 }148 ```14915012. **SHOULD: Use `Result` types for expected failures instead of throwing.** Throwing for control flow forces callers to know which functions throw and what.151 ```ts152 type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };153154 function parseConfig(raw: string): Result<Config> {155 try {156 return { ok: true, value: JSON.parse(raw) };157 } catch (e) {158 return { ok: false, error: e instanceof Error ? e : new Error(String(e)) };159 }160 }161 ```162163---164165## Performance16616713. **MUST: Use `Promise.all` for independent async operations, not sequential `await`.** Sequential awaits multiply latency.168 ```ts169 // Wrong — 300ms total if each takes 100ms170 const user = await getUser(id);171 const orders = await getOrders(id);172 const prefs = await getPrefs(id);173174 // Correct — 100ms total175 const [user, orders, prefs] = await Promise.all([176 getUser(id),177 getOrders(id),178 getPrefs(id),179 ]);180 ```18118214. **SHOULD: Use `Promise.allSettled` when partial failure is acceptable.** `Promise.all` rejects on the first failure. `allSettled` collects all results.183 ```ts184 const results = await Promise.allSettled(ids.map(fetchItem));185 const succeeded = results186 .filter((r): r is PromiseFulfilledResult<Item> => r.status === 'fulfilled')187 .map(r => r.value);188 ```189190---191192## Testing19319415. **MUST: Type test helpers and mocks — never use `as any` to silence mock errors.** Untyped mocks let type errors hide until runtime. *Exception: prototyping spikes that are explicitly thrown away before merge.*195 ```ts196 // Wrong197 const mockUser = { id: '1' } as any;198199 // Correct200 const mockUser: User = { id: createUserId('1'), email: 'a@b.com', name: 'Alice' };201 ```20220316. **SHOULD: Test the discriminated union branches explicitly.** Each `status` variant is a separate code path. One test per branch minimum.204 ```ts205 it('renders error state', () => {206 const state: Request = { status: 'error', error: new Error('timeout') };207 render(<RequestView state={state} />);208 expect(screen.getByRole('alert')).toHaveTextContent('timeout');209 });210 ```21121217. **SHOULD: Use `expectTypeOf` or `assertType` for type-level tests.** Runtime tests cannot catch type regressions.213 ```ts214 import { expectTypeOf } from 'vitest';215 expectTypeOf(createUserId('x')).toEqualTypeOf<UserId>();216 ```217218---219220## Why This Sub-Skill Earns Stars221222These rules target the gap between "TypeScript that compiles" and "TypeScript that is safe". LLMs default to `any`, skip discriminated unions, and reach for `as` assertions because they are the path of least resistance. Each rule here closes a specific escape hatch that lets type errors reach production. The MUST/SHOULD/AVOID classification means safety-critical rules are strict and stylistic rules respect context.