TypeScript Best Practices
Use this skill as the default guidance for any .ts or .tsx change.
Keep this file practical. Record the repo's actual TypeScript standards here.
References
- For review or review-and-fix requests on a working tree, branch, commit, or commit range, read
references/review-and-fix-changes.md and use it as the workflow while keeping this file as the
review rubric.
Intent
- Provide a single baseline for how TypeScript should be written and refactored.
- Keep code strongly typed, easy to read, and easy to change.
- Reduce AI slop such as defensive cleanup, vague abstractions, duplicate transforms, and weak
fallbacks.
Fast Path
- Validate untyped JSON with Zod at the boundary.
- Keep functions small and composable.
- Keep boundary shapes separate from internal domain shapes.
- Convert external data once, then keep internal code strict and simple.
- Do not normalize values unless there is a real boundary or product reason.
- Preserve original source values; add derived internal keys when needed.
- Use
null for meaningful absence in stable domain data.
- Reserve
undefined for optional inputs and raw boundary payload fields.
- Reuse existing helpers before adding new ones.
- For string, number, URL, array, dictionary, dedupe, filter, or similar foundational logic,
search for an existing shared helper location in the repo first and add shared logic there
instead of burying it in feature code.
- Extract shared invariants and boundary adapters; do not invent generic cleanup utilities.
- Run a quick duplicate-code smoke test with
jscpd after refactors and before review.
- Do not add or expand code comments unless the user explicitly asks for comments in code.
Priority Order
- Follow project-specific conventions first.
- Make boundaries typed and explicit.
- Reuse existing helpers and invariants before adding new abstractions.
- Extract repeated boundary adapters or invariants once repetition is real.
- Prefer clarity over cleverness.
- Apply style preferences only after correctness, boundaries, and reuse are settled.
Core Rules
1. Validate and coerce untyped boundary data once, then keep domain types strict.
Bad:
type Issue = {
id: string;
attemptCount: number;
};
function parseIssue({ body }: { body: string }): Issue {
return JSON.parse(body) as Issue;
}
Good:
import { z } from "zod";
const issueSchema = z.object({
id: z.string().min(1),
attemptCount: z.coerce.number().int().nonnegative(),
});
type Issue = z.infer<typeof issueSchema>;
function parseIssue({ body }: { body: string }): Issue {
return issueSchema.parse(JSON.parse(body) as unknown);
}
2. Keep functions small and composable. Use a params object when a function takes multiple authored parameters.
- Prefer
function name() declarations over const name = () => {} for authored functions.
Bad:
function buildWorkspaceLabel(issue: Issue, ownerName?: string): string {
const normalizedState = issue.state.trim().toLowerCase();
const normalizedOwner = ownerName ? ownerName.trim() : "unassigned";
const slug = `${issue.identifier}-${normalizedState}-${normalizedOwner}`
.replaceAll(/[^\w-]+/g, "-")
.replaceAll(/-+/g, "-")
.replace(/^-|-$/g, "");
if (!slug) {
return "workspace";
}
return slug;
}
Good:
function buildWorkspaceLabel(params: {
issue: Issue;
ownerName?: string;
}): string {
const stateKey = buildStateKey(params.issue.state);
const ownerKey = buildOwnerKey(params.ownerName);
return buildWorkspaceSlug({
identifier: params.issue.identifier,
stateKey,
ownerKey,
});
}
function buildOwnerKey(ownerName?: string): string {
return ownerName ?? "unassigned";
}
3. Put foundational string, number, URL, array, dictionary, dedupe, and filter logic in shared helpers.
Bad:
function listUniqueIssueIds(issues: Issue[]): string[] {
return [...new Set(issues.map((issue) => issue.id))];
}
Good:
import { unique } from "../shared/collections";
function listUniqueIssueIds(issues: Issue[]): string[] {
return unique(issues.map((issue) => issue.id));
}
If no shared helper exists yet, search for an existing shared helper location first. For purely
foundational logic like strings, numbers, URLs, arrays, dictionaries, deduping, or filtering, add
the reusable helper there instead of embedding it inside a feature module. Always look for utils/helpers/etc folders for these
4. Do not write code comments unless the user explicitly requests them.
- This includes line comments, block comments, JSDoc, TODOs, commented-out code, and directive
comments.
- Existing comments, neighboring style, lint rules, and documentation do not count as explicit
user instruction.
- Express intent with precise names, types, small functions, and clear structure.
- Preserve accurate existing comments. If a change makes one stale, remove it instead of
rewriting it unless the user explicitly requested code comments.
5. Prefer explicit boundary errors over defensive defaults.
Bad:
function parseLimit(value?: string): number {
return Number(value) || 0;
}
Good:
const limitSchema = z.coerce.number().int().nonnegative();
function parseLimit(value: string): number {
return limitSchema.parse(value);
}
Refactoring Rules
- Before adding a new helper, search the repo for an existing helper, transform, or adapter that
already solves the same problem.
- For foundational logic on strings, numbers, URLs, arrays, dictionaries, deduping, or filtering,
search for shared helper files first and add that logic there before introducing a feature-local
helper.
- If the same invariant, transform, or adapter appears in multiple places, prefer extracting it
over adding another copy.
- Extract after real repetition, not because two snippets look vaguely similar.
- Shared helpers should have a clear domain purpose.
- Do not introduce broad
sanitize*, normalize*, clean*, or format* utilities that mix
unrelated concerns.
- If an abstraction only exists to generically "clean up" values, the real fix is usually at a
typed boundary.
- When refactoring, preserve the strongest existing types. Do not widen types just to make the
refactor easier.
- Prefer small boundary adapters and invariant helpers over abstract utility layers.
Duplicate-Code Smoke Test
Use jscpd as a quick duplicate-code smoke test after refactors and before review, like a
typecheck. From the current repository root, run:
cd "$(git rev-parse --show-toplevel)"
bunx --yes jscpd --format typescript --pattern '**/*.ts' --gitignore \
--min-lines 12 --min-tokens 120 \
--max-lines 5000 --max-size 1mb --reporters console --exitCode 0 .
- Keep tests ignored at first because fixtures often create noise; add repo-appropriate ignores or
scope the pattern to production code when needed.
- Lower
--min-tokens to 60-80 when hunting smaller helper duplication.
- Use
--reporters html,console when a browsable report would help.
- Pair clone detection with targeted
rg for semantic duplicates: repeated function names,
comments, payload fields, or error strings. Exact clone tools do not catch same logic with
different spelling.
Code Comment Rules
- Do not add or expand code comments unless the user explicitly requests them.
- Treat JSDoc, TODOs, explanatory blocks, commented-out code, and directive comments as code
comments under this rule.
- Do not infer permission from existing comments, neighboring style, lint rules, generated
examples, or documentation.
- Preserve accurate existing comments. If changed code makes one stale, remove it instead of
rewriting it unless the user explicitly requested code comments.
- Make the code self-explanatory through precise names, strong types, small helpers, and clear
structure.
Workflow
- Read this skill before making TypeScript changes.
- For review or review-and-fix tasks, read
references/review-and-fix-changes.md before scoping
the work.
- Follow project-specific conventions first when they conflict with generic guidance.
- When data crosses into the system as JSON, define a Zod schema at that boundary.
- When values are already trusted and typed, avoid unnecessary cleanup or coercion.
- Keep functions small and composable.
- For functions with multiple authored parameters, usually prefer
fn(params: { ... }).
- For functions with a single parameter, pass the value directly instead of wrapping it in a
params object unless there is a strong local reason not to.
- Do not add or expand code comments unless the user explicitly requested them.
- If the change is purely about strings, numbers, URLs, arrays, dictionaries, deduping,
filtering, or similar foundational types, search for an existing shared helper location in the
repo first and add the shared logic there.
- After a refactor, run a quick
jscpd duplicate-code smoke test when the repo/tooling can
support it.
- If the code seems to require
as, stop and look for a safer typing or validation approach
first.
- Before adding a new helper or abstraction, search the repo for an existing pattern you should
reuse.
- If similar logic exists in multiple modules, prefer extracting a shared invariant or boundary
adapter over adding another copy.
- Use subagents to inspect the codebase for existing helpers, repeated transforms, and refactor
opportunities before introducing a new abstraction when subagents are available.
- Use subagents for bounded multi-file refactors when the write scope is clear, but avoid broad
reusable utilities unless the repetition is real.
- If a proposed abstraction only exists to generically "clean up" values, stop and check whether
the real fix belongs at a typed boundary instead.
Red Flags
JSON.parse(...) as Foo
Number(value) || 0
input.trim().toLowerCase() with no product reason
- repeated
foo ?? default across business logic
- generic
sanitize* or normalize* helpers with vague scope
- large feature-local helpers that should be split into small composable functions
- new feature-local string, number, URL, array, or dictionary helpers that should live in a shared
helper file
- duplicated boundary transforms across multiple files
- domain types carrying both
null and undefined without a good reason
- broad interfaces or classes passed through layers that only need one or two methods
- newly added or expanded code comments without an explicit user request
- stale existing comments left behind after the code they describe changes
Review Checklist
- Is raw JSON being validated with Zod instead of cast?
- Are functions small and composable?
- Is boundary parsing happening once and early?
- Are boundary DTOs or view shapes translated once into internal domain types?
- Is external payload variance handled at the boundary rather than leaking into internal logic?
- Is the code using
null and undefined deliberately instead of mixing both through domain
code?
- Is the code normalizing values without a clear boundary or product reason?
- If normalization is required, does the code preserve the original value and add a derived key?
- Is there already an existing helper, transform, or adapter in the repo that should be reused?
- For foundational string, number, URL, array, dictionary, dedupe, or filter logic, did the code
search for and use a shared helper file instead of adding feature-local duplication?
- Should repeated invariants or boundary adapters be extracted instead of copied again?
- Did a
jscpd smoke test or targeted rg search identify duplicate code worth refactoring?
- Is
as being used where stronger checks or better types should exist instead?
- Are domain invariants modeled with exact types like unions,
Map, Set, or as const values?
- Does the code depend on the smallest capability surface it needs?
- Did the change avoid adding or expanding code comments unless the user explicitly requested
them?
- Were stale existing comments removed instead of rewritten when comments were not requested?
- Is the code hiding broken assumptions behind weak defaults or silent fallbacks?
1---2name: typescript-best-practices3description: Use whenever writing, editing, refactoring, or reviewing TypeScript or TSX code. This skill defines how TypeScript should be modeled, how boundaries and shared helpers should be handled, how intent should be expressed without code comments, and how to keep code clear, DRY, and free of defensive AI slop. For review workflows, read references/review-and-fix-changes.md.4---56# TypeScript Best Practices78Use this skill as the default guidance for any `.ts` or `.tsx` change.910Keep this file practical. Record the repo's actual TypeScript standards here.1112## References1314- For review or review-and-fix requests on a working tree, branch, commit, or commit range, read15 `references/review-and-fix-changes.md` and use it as the workflow while keeping this file as the16 review rubric.1718## Intent1920- Provide a single baseline for how TypeScript should be written and refactored.21- Keep code strongly typed, easy to read, and easy to change.22- Reduce AI slop such as defensive cleanup, vague abstractions, duplicate transforms, and weak23 fallbacks.2425## Fast Path2627- Validate untyped JSON with Zod at the boundary.28- Keep functions small and composable.29- Keep boundary shapes separate from internal domain shapes.30- Convert external data once, then keep internal code strict and simple.31- Do not normalize values unless there is a real boundary or product reason.32- Preserve original source values; add derived internal keys when needed.33- Use `null` for meaningful absence in stable domain data.34- Reserve `undefined` for optional inputs and raw boundary payload fields.35- Reuse existing helpers before adding new ones.36- For string, number, URL, array, dictionary, dedupe, filter, or similar foundational logic,37 search for an existing shared helper location in the repo first and add shared logic there38 instead of burying it in feature code.39- Extract shared invariants and boundary adapters; do not invent generic cleanup utilities.40- Run a quick duplicate-code smoke test with `jscpd` after refactors and before review.41- Do not add or expand code comments unless the user explicitly asks for comments in code.4243## Priority Order44451. Follow project-specific conventions first.462. Make boundaries typed and explicit.473. Reuse existing helpers and invariants before adding new abstractions.484. Extract repeated boundary adapters or invariants once repetition is real.495. Prefer clarity over cleverness.506. Apply style preferences only after correctness, boundaries, and reuse are settled.5152## Core Rules5354### 1. Validate and coerce untyped boundary data once, then keep domain types strict.5556Bad:5758```ts59type Issue = {60 id: string;61 attemptCount: number;62};6364function parseIssue({ body }: { body: string }): Issue {65 return JSON.parse(body) as Issue;66}67```6869Good:7071```ts72import { z } from "zod";7374const issueSchema = z.object({75 id: z.string().min(1),76 attemptCount: z.coerce.number().int().nonnegative(),77});7879type Issue = z.infer<typeof issueSchema>;8081function parseIssue({ body }: { body: string }): Issue {82 return issueSchema.parse(JSON.parse(body) as unknown);83}84```8586### 2. Keep functions small and composable. Use a `params` object when a function takes multiple authored parameters.8788- Prefer `function name()` declarations over `const name = () => {}` for authored functions.8990Bad:9192```ts93function buildWorkspaceLabel(issue: Issue, ownerName?: string): string {94 const normalizedState = issue.state.trim().toLowerCase();95 const normalizedOwner = ownerName ? ownerName.trim() : "unassigned";96 const slug = `${issue.identifier}-${normalizedState}-${normalizedOwner}`97 .replaceAll(/[^\w-]+/g, "-")98 .replaceAll(/-+/g, "-")99 .replace(/^-|-$/g, "");100101 if (!slug) {102 return "workspace";103 }104105 return slug;106}107```108109Good:110111```ts112function buildWorkspaceLabel(params: {113 issue: Issue;114 ownerName?: string;115}): string {116 const stateKey = buildStateKey(params.issue.state);117 const ownerKey = buildOwnerKey(params.ownerName);118119 return buildWorkspaceSlug({120 identifier: params.issue.identifier,121 stateKey,122 ownerKey,123 });124}125126function buildOwnerKey(ownerName?: string): string {127 return ownerName ?? "unassigned";128}129```130131### 3. Put foundational string, number, URL, array, dictionary, dedupe, and filter logic in shared helpers.132133Bad:134135```ts136function listUniqueIssueIds(issues: Issue[]): string[] {137 return [...new Set(issues.map((issue) => issue.id))];138}139```140141Good:142143```ts144import { unique } from "../shared/collections";145146function listUniqueIssueIds(issues: Issue[]): string[] {147 return unique(issues.map((issue) => issue.id));148}149```150151If no shared helper exists yet, search for an existing shared helper location first. For purely152foundational logic like strings, numbers, URLs, arrays, dictionaries, deduping, or filtering, add153the reusable helper there instead of embedding it inside a feature module. Always look for utils/helpers/etc folders for these154155### 4. Do not write code comments unless the user explicitly requests them.156157- This includes line comments, block comments, JSDoc, TODOs, commented-out code, and directive158 comments.159- Existing comments, neighboring style, lint rules, and documentation do not count as explicit160 user instruction.161- Express intent with precise names, types, small functions, and clear structure.162- Preserve accurate existing comments. If a change makes one stale, remove it instead of163 rewriting it unless the user explicitly requested code comments.164165### 5. Prefer explicit boundary errors over defensive defaults.166167Bad:168169```ts170function parseLimit(value?: string): number {171 return Number(value) || 0;172}173```174175Good:176177```ts178const limitSchema = z.coerce.number().int().nonnegative();179180function parseLimit(value: string): number {181 return limitSchema.parse(value);182}183```184185## Refactoring Rules186187- Before adding a new helper, search the repo for an existing helper, transform, or adapter that188 already solves the same problem.189- For foundational logic on strings, numbers, URLs, arrays, dictionaries, deduping, or filtering,190 search for shared helper files first and add that logic there before introducing a feature-local191 helper.192- If the same invariant, transform, or adapter appears in multiple places, prefer extracting it193 over adding another copy.194- Extract after real repetition, not because two snippets look vaguely similar.195- Shared helpers should have a clear domain purpose.196- Do not introduce broad `sanitize*`, `normalize*`, `clean*`, or `format*` utilities that mix197 unrelated concerns.198- If an abstraction only exists to generically "clean up" values, the real fix is usually at a199 typed boundary.200- When refactoring, preserve the strongest existing types. Do not widen types just to make the201 refactor easier.202- Prefer small boundary adapters and invariant helpers over abstract utility layers.203204## Duplicate-Code Smoke Test205206Use `jscpd` as a quick duplicate-code smoke test after refactors and before review, like a207typecheck. From the current repository root, run:208209```bash210cd "$(git rev-parse --show-toplevel)"211bunx --yes jscpd --format typescript --pattern '**/*.ts' --gitignore \212 --min-lines 12 --min-tokens 120 \213 --max-lines 5000 --max-size 1mb --reporters console --exitCode 0 .214```215216- Keep tests ignored at first because fixtures often create noise; add repo-appropriate ignores or217 scope the pattern to production code when needed.218- Lower `--min-tokens` to `60`-`80` when hunting smaller helper duplication.219- Use `--reporters html,console` when a browsable report would help.220- Pair clone detection with targeted `rg` for semantic duplicates: repeated function names,221 comments, payload fields, or error strings. Exact clone tools do not catch same logic with222 different spelling.223224## Code Comment Rules225226- Do not add or expand code comments unless the user explicitly requests them.227- Treat JSDoc, TODOs, explanatory blocks, commented-out code, and directive comments as code228 comments under this rule.229- Do not infer permission from existing comments, neighboring style, lint rules, generated230 examples, or documentation.231- Preserve accurate existing comments. If changed code makes one stale, remove it instead of232 rewriting it unless the user explicitly requested code comments.233- Make the code self-explanatory through precise names, strong types, small helpers, and clear234 structure.235236## Workflow2372381. Read this skill before making TypeScript changes.2392. For review or review-and-fix tasks, read `references/review-and-fix-changes.md` before scoping240 the work.2413. Follow project-specific conventions first when they conflict with generic guidance.2424. When data crosses into the system as JSON, define a Zod schema at that boundary.2435. When values are already trusted and typed, avoid unnecessary cleanup or coercion.2446. Keep functions small and composable.2457. For functions with multiple authored parameters, usually prefer `fn(params: { ... })`.2468. For functions with a single parameter, pass the value directly instead of wrapping it in a247 params object unless there is a strong local reason not to.2489. Do not add or expand code comments unless the user explicitly requested them.24910. If the change is purely about strings, numbers, URLs, arrays, dictionaries, deduping,250 filtering, or similar foundational types, search for an existing shared helper location in the251 repo first and add the shared logic there.25211. After a refactor, run a quick `jscpd` duplicate-code smoke test when the repo/tooling can253 support it.25412. If the code seems to require `as`, stop and look for a safer typing or validation approach255 first.25613. Before adding a new helper or abstraction, search the repo for an existing pattern you should257 reuse.25814. If similar logic exists in multiple modules, prefer extracting a shared invariant or boundary259 adapter over adding another copy.26015. Use subagents to inspect the codebase for existing helpers, repeated transforms, and refactor261 opportunities before introducing a new abstraction when subagents are available.26216. Use subagents for bounded multi-file refactors when the write scope is clear, but avoid broad263 reusable utilities unless the repetition is real.26417. If a proposed abstraction only exists to generically "clean up" values, stop and check whether265 the real fix belongs at a typed boundary instead.266267## Red Flags268269- `JSON.parse(...) as Foo`270- `Number(value) || 0`271- `input.trim().toLowerCase()` with no product reason272- repeated `foo ?? default` across business logic273- generic `sanitize*` or `normalize*` helpers with vague scope274- large feature-local helpers that should be split into small composable functions275- new feature-local string, number, URL, array, or dictionary helpers that should live in a shared276 helper file277- duplicated boundary transforms across multiple files278- domain types carrying both `null` and `undefined` without a good reason279- broad interfaces or classes passed through layers that only need one or two methods280- newly added or expanded code comments without an explicit user request281- stale existing comments left behind after the code they describe changes282283## Review Checklist284285- Is raw JSON being validated with Zod instead of cast?286- Are functions small and composable?287- Is boundary parsing happening once and early?288- Are boundary DTOs or view shapes translated once into internal domain types?289- Is external payload variance handled at the boundary rather than leaking into internal logic?290- Is the code using `null` and `undefined` deliberately instead of mixing both through domain291 code?292- Is the code normalizing values without a clear boundary or product reason?293- If normalization is required, does the code preserve the original value and add a derived key?294- Is there already an existing helper, transform, or adapter in the repo that should be reused?295- For foundational string, number, URL, array, dictionary, dedupe, or filter logic, did the code296 search for and use a shared helper file instead of adding feature-local duplication?297- Should repeated invariants or boundary adapters be extracted instead of copied again?298- Did a `jscpd` smoke test or targeted `rg` search identify duplicate code worth refactoring?299- Is `as` being used where stronger checks or better types should exist instead?300- Are domain invariants modeled with exact types like unions, `Map`, `Set`, or `as const` values?301- Does the code depend on the smallest capability surface it needs?302- Did the change avoid adding or expanding code comments unless the user explicitly requested303 them?304- Were stale existing comments removed instead of rewritten when comments were not requested?305- Is the code hiding broken assumptions behind weak defaults or silent fallbacks?