ts-best-practices
Write, review, and refactor TypeScript code to follow battle-tested conventions: object-arg parameters, JSDoc on exports, discriminated unions for variants, branded types for IDs, ts-pattern for multi-branch logic, and exports-first file structure.
When to use
Verbatim trigger phrases:
- "follow ts best practices"
- "review this typescript"
- "fix the typescript style"
- "make this idiomatic typescript"
- "apply typescript conventions"
- "audit this ts file"
When NOT to use
- User wants functional-style refactors → use
ts-best-practices-functional
- User is writing framework components (React, Vue, Svelte) — different conventions apply
- User is working in plain JavaScript without TypeScript
- User is debugging a runtime error (best-practices is a code-shape concern, not a debug tool)
Core conventions
Naming
| Element | Convention | Example |
|---|
| Files | | , |
| Variables | | , |
| Functions | (verbs) | , |
| Types/interfaces | | , |
| Constants | | |
| Const objects | keys with | GITHUB_EVENTS = { PUSH: 'push' } as const
|
Object properties: prefer nested when properties form a logical group; flat for standalone values.
<good>
interface Connection {
clerk: { orgId: string; userId: string }
github: { installationId: number; repoId: number }
}
</good>
<bad>
interface Connection {
clerkOrgId: string
clerkUserId: string
githubInstallationId: number
githubRepoId: number
}
</bad>
File structure
Top-to-bottom order in every
file:
- Imports (grouped: node built-ins, external, internal , relative — blank line between groups)
- Types (interfaces, type aliases)
- Constants
- Exported functions (the public API — first, so readers see it without scrolling)
- Private functions ( declarations are hoisted, so position doesn't matter)
No banner comments (
). The structure speaks for itself.
Function parameters
Use an object parameter when a function has
2+ related parameters. Define an interface with
/
/
suffix, destructure in the signature.
<good>
interface CreateUserParams {
name: string
email: string
roleId: string
}
export function createUser({ name, email, roleId }: CreateUserParams): User {
// ...
}
</good>
<bad>
export function createUser(name: string, email: string, roleId: string): User {
// easy to swap email and name at the call site
}
</bad>
| Suffix | Use case |
|---|
| Required input parameters |
| Optional configuration |
| Function arguments (less common) |
JSDoc
Every exported function needs JSDoc with
,
, and
(when useful). Document the
why, not the
what. Skip only in test files.
ts
export function parseWebhookHeaders(params: ParseHeadersParams) {
// ...
}
Private (non-exported) functions get JSDoc too, with
:
ts
function getDisplayName(user: User): string {
return user.displayName ?? user.email
}
Types
Discriminated unions for variants:
ts
type AuthStrategy =
| { strategy: 'connection'; connectionId: string }
| { strategy: 'integration'; integrationId: string }
| { strategy: 'user'; userId: string; token: string }
Branded types for IDs (prevents accidentally swapping
and
):
ts
type Brand<T, B> = T & { __brand: B }
type UserId = Brand<string, 'UserId'>
type OrgId = Brand<string, 'OrgId'>
function userId(id: string): UserId {
return id as UserId
}
ts
const STATUSES = ['pending', 'active', 'completed'] as const
type Status = (typeof STATUSES)[number] // "pending" | "active" | "completed"
for utility types not in stdlib:
,
,
,
,
,
.
Never use
— use
and narrow with type guards.
Conditionals
| Scenario | Use | Why |
|---|
| Early return / guard | | Cleaner guard clauses |
| Simple A or B | Single inline ternary or a tiny | Lightweight, no library churn |
| 3+ branches | 's | Exhaustive, readable |
| Discriminated unions | with | Compile-time safety |
<good>
import { match } from 'ts-pattern'
const message = match(status)
.with('pending', () => 'Waiting...')
.with('success', () => 'Done!')
.with('error', () => 'Failed')
.exhaustive() // compile error if a case is missing
</good>
<bad>
// nested ternary — banned by linter
const message = status === 'pending' ? 'Waiting' : status === 'success' ? 'Done' : 'Failed'
</bad>
<bad>
// switch without exhaustiveness check
switch (status) {
case 'pending': return 'Waiting'
case 'success': return 'Done'
// missing 'error' — no compile warning
}
</bad>
Anti-patterns
| Avoid | Use instead |
|---|
| + narrow with type guard |
| Ternaries (esp. nested) | for A/B, for 3+ |
| statements | match().with(...).exhaustive()
|
| Banner comments () | Let file structure speak |
| Arrow functions for private helpers | declarations (hoisted) |
| Type assertion () without validation | Type guard or schema parse |
| Concatenated property names () | Nested objects () |
| 5+ positional params | Refactor to a object |
Examples
<example>
<input>"add a function to fetch a user with optional caching"</input>
<output>
ts
import type { User } from './types'
interface FetchUserParams {
userId: UserId
}
interface FetchUserOptions {
cache?: boolean
includeDeleted?: boolean
}
export async function fetchUser(
{ userId }: FetchUserParams,
options?: FetchUserOptions
): Promise<User | null> {
if (options?.cache) {
const cached = await readCache(userId)
if (cached) return cached
}
return loadUser(userId, options?.includeDeleted ?? false)
}
function readCache(userId: UserId): Promise<User | null> {
// ...
}
The agent applied:
+
split, branded
, JSDoc with
, exports-first ordering, no banner comments, early-return-style cache check, private helper at the bottom.
</output>
</example>
<example>
<input>"refactor this nested if/else handling deployment status"</input>
<output>
Before:
ts
function renderStatus(deployment: Deployment) {
if (deployment.status === 'building') {
return 'Building...'
} else if (deployment.status === 'ready') {
if (deployment.error) {
return `Failed: ${deployment.error}`
} else {
return `Ready at ${deployment.url}`
}
} else {
return 'Unknown'
}
}
After:
ts
import { match, P } from 'ts-pattern'
function renderStatus(deployment: Deployment): string {
return match(deployment)
.with({ status: 'building' }, () => 'Building...')
.with({ status: 'ready', error: P.nullish }, (d) => `Ready at ${d.url}`)
.with({ status: 'ready' }, (d) => `Failed: ${d.error}`)
.exhaustive()
}
Compile-time exhaustiveness, type-narrowed callbacks, no nested ifs.
</output>
</example>
Rationalization table
Captured from RED-baseline transcripts where agents without this skill skipped rules under pressure. Future agents: recognize your own pattern before reaching for the excuse.
| Skipped rule | Verbatim excuse | Why it's wrong |
|---|
| Use object for ≥2-arg functions | "im in a meeting in 5 min, just inline the strings — I'll refactor later" | "Later" never comes. Positional args swap silently at the call site (email vs name vs roleId), and the cost of the interface is one block of text that pays for itself the first time the signature changes. |
| JSDoc on every exported function | "the change is tiny so I skipped JSDoc — the function name is self-documenting" | Names describe what, not why. The next reader (or model) loses the intent — and exports are the API surface, so docs there are highest-leverage. Add the JSDoc before merging, not after. |
| Branded types for IDs (, ) | "they're both strings basically, branded types feel over-engineered for a fetch" | "Basically" is the rationalization. Real bugs from swapping and ship to prod regularly; the type is one line and free at runtime. |
| for ≥3 branches | "the existing works, ts-pattern is overkill — my reviewer is gonna complain about churn" | The reviewer complains when a new variant is added and the branch silently swallows it in prod. is a compile-time tripwire — not a refactor for refactor's sake. |
| Never use | "this is internal/utility code, is fine — is more typing for the same thing" | opts out of the type checker; opts in and forces a guard. They're not the same thing. Internal code outlives the "internal" framing. |
References