ts-best-practices
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesets-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.
Claude Code扩展(其他Agent会忽略)--- argument-hint: '[<file-or-dir>]' user-invocable: true model-invocable: true
When to use
ts-best-practices
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"
编写、审查和重构TypeScript代码,遵循经过实战检验的约定:对象参数、导出项上的JSDoc、用于变体的可区分联合、用于ID的品牌类型、用于多分支逻辑的ts-pattern,以及导出优先的文件结构。
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)
触发短语示例:
- "遵循ts最佳实践"
- "审查这段typescript代码"
- "修复typescript代码风格"
- "使其成为符合规范的typescript代码"
- "应用typescript约定"
- "审核这个ts文件"
Core conventions
不适用场景
Naming
—
| Element | Convention | Example |
|---|---|---|
| Files | | |
| Variables | | |
| Functions | | |
| Types/interfaces | | |
| Constants | | |
| Const objects | | |
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>- 用户想要函数式风格重构 → 使用
ts-best-practices-functional - 用户正在编写框架组件(React、Vue、Svelte)—— 这些框架有不同的约定
- 用户正在使用纯JavaScript而非TypeScript
- 用户正在调试运行时错误(最佳实践关注代码形态,而非调试工具)
File structure
核心约定
—
命名规范
Top-to-bottom order in every file:
.ts- Imports (grouped: node built-ins, external, internal , relative — blank line between groups)
@pkg/* - 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)
function
No banner comments (). The structure speaks for itself.
// === HELPERS ===| 元素 | 约定 | 示例 |
|---|---|---|
| 文件 | | |
| 变量 | | |
| 函数 | | |
| 类型/接口 | | |
| 常量 | | |
| 常量对象 | 键使用 | |
对象属性:当属性形成逻辑分组时,优先使用嵌套结构;独立值则使用扁平结构。
<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>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
}
ParamsOptionsArgsexport 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) |
每个文件从上到下的顺序:
.ts- 导入语句(分组:Node.js内置模块、外部模块、内部模块、相对路径模块 —— 组之间用空行分隔)
@pkg/* - 类型定义(接口、类型别名)
- 常量
- 导出函数(公开API —— 放在最前面,让读者无需滚动就能看到)
- 私有函数(声明会被提升,所以位置不影响)
function
不要使用横幅注释(如)。文件结构本身就能说明问题。
// === HELPERS ===JSDoc
函数参数
Every exported function needs JSDoc with , , and (when useful). Document the why, not the what. Skip only in test files.
@param@returns@examplets
/**
* Parses raw webhook headers into a typed structure.
*
* @param params - The raw headers and provider identifier
* @returns Parsed webhook headers or a parse error
*
* @example
* ```ts
* const result = parseWebhookHeaders({ headers, provider: 'github' })
* ```
*/
export function parseWebhookHeaders(params: ParseHeadersParams) {
// ...
}Private (non-exported) functions get JSDoc too, with :
@privatets
/**
* Extracts the display name from a user record.
*
* @private
*/
function getDisplayName(user: User): string {
return user.displayName ?? user.email
}当函数有2个及以上相关参数时,使用对象参数。定义一个后缀为//的接口,并在函数签名中解构。
<good>
interface CreateUserParams {
name: string
email: string
roleId: string
}
ParamsOptionsArgsexport function createUser({ name, email, roleId }: CreateUserParams): User {
// ...
}
</good>
<bad>
export function createUser(name: string, email: string, roleId: string): User {
// 调用时容易混淆email和name的顺序
}
</bad>
| 后缀 | 使用场景 |
|---|---|
| 必填输入参数 |
| 可选配置参数 |
| 函数参数(较少使用) |
Types
JSDoc
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 ):
userIdorgIdts
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
}as constts
const STATUSES = ['pending', 'active', 'completed'] as const
type Status = (typeof STATUSES)[number] // "pending" | "active" | "completed"type-festSetRequiredSetOptionalPartialDeepReadonlyDeepExceptSimplifyNever use — use and narrow with type guards.
anyunknown每个导出函数都需要带有、和(有用时)的JSDoc。要说明原因,而非内容。仅在测试文件中可以省略。
@param@returns@examplets
/**
* 将原始Webhook头解析为类型化结构。
*
* @param params - 原始头信息和提供商标识符
* @returns 解析后的Webhook头或解析错误
*
* @example
* ```ts
* const result = parseWebhookHeaders({ headers, provider: 'github' })
* ```
*/
export function parseWebhookHeaders(params: ParseHeadersParams) {
// ...
}私有(非导出)函数也需要JSDoc,并添加标记:
@privatets
/**
* 从用户记录中提取显示名称。
*
* @private
*/
function getDisplayName(user: User): string {
return user.displayName ?? user.email
}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 | | Exhaustive, readable |
| Discriminated unions | | Compile-time safety |
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>可区分联合用于变体类型:
ts
type AuthStrategy =
| { strategy: 'connection'; connectionId: string }
| { strategy: 'integration'; integrationId: string }
| { strategy: 'user'; userId: string; token: string }品牌类型用于ID(防止意外混淆和):
userIdorgIdts
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
}****用于字面量类型:
as constts
const STATUSES = ['pending', 'active', 'completed'] as const
type Status = (typeof STATUSES)[number] // "pending" | "active" | "completed"****用于标准库中没有的工具类型:, , , , , 。
type-festSetRequiredSetOptionalPartialDeepReadonlyDeepExceptSimplify绝对不要使用 —— 使用并通过类型守卫进行收窄。
anyunknownAnti-patterns
条件判断
| Avoid | Use instead |
|---|---|
| |
| Ternaries (esp. nested) | |
| |
Banner comments ( | Let file structure speak |
| Arrow functions for private helpers | |
Type assertion ( | Type guard or schema parse |
Concatenated property names ( | Nested objects ( |
| 5+ positional params | Refactor to a |
| 场景 | 使用方式 | 原因 |
|---|---|---|
| 提前返回/守卫 | | 更简洁的守卫逻辑 |
| 简单二选一 | 单行三元表达式或简短的 | 轻量,无需引入额外库 |
| 3个及以上分支 | | 穷举性,可读性强 |
| 可区分联合 | | 编译时安全检查 |
const message = match(status)
.with('pending', () => 'Waiting...')
.with('success', () => 'Done!')
.with('error', () => 'Failed')
.exhaustive() // 如果遗漏分支会触发编译错误
</good>
<bad>
// 嵌套三元表达式 —— 被代码检查工具禁止
const message = status === 'pending' ? 'Waiting' : status === 'success' ? 'Done' : 'Failed'
</bad>
<bad>
// 没有穷举检查的switch语句
switch (status) {
case 'pending': return 'Waiting'
case 'success': return 'Done'
// 遗漏'error'分支 —— 无编译警告
}
</bad>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
}
/**
* Fetches a user by ID, optionally with caching.
*
* @param params - Required: the user ID to fetch
* @param options - Optional: cache + soft-delete flags
* @returns The user record, or null if not found
*
* @example
* ```ts
* const user = await fetchUser({ userId }, { cache: true })
* ```
*/
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)
}
/**
* @private
*/
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>
*Params*OptionsUserId@exampleBefore:
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>| 需避免的写法 | 替代方案 |
|---|---|
| |
| 三元表达式(尤其是嵌套的) | 二选一用 |
| |
横幅注释(如 | 依靠文件结构来区分 |
| 私有辅助函数使用箭头函数 | |
无验证的类型断言( | 类型守卫或模式解析 |
拼接属性名(如 | 嵌套对象(如 |
| 5个及以上位置参数 | 重构为 |
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 | "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 |
| "the existing | The reviewer complains when a new variant is added and the |
Never use | "this is internal/utility code, | |
<example>
<input>"添加一个支持可选缓存的用户获取函数"</input>
<output>
ts
import type { User } from './types'
interface FetchUserParams {
userId: UserId
}
interface FetchUserOptions {
cache?: boolean
includeDeleted?: boolean
}
/**
* 根据ID获取用户,可选启用缓存。
*
* @param params - 必填:要获取的用户ID
* @param options - 可选:缓存和软删除标记
* @returns 用户记录,若未找到则返回null
*
* @example
* ```ts
* const user = await fetchUser({ userId }, { cache: true })
* ```
*/
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)
}
/**
* @private
*/
function readCache(userId: UserId): Promise<User | null> {
// ...
}Agent应用了以下规范: + 分离、品牌化、带的JSDoc、导出优先的顺序、无横幅注释、提前返回式缓存检查、底部的私有辅助函数。
</output>
</example>
<example>
<input>"重构这段处理部署状态的嵌套if/else代码"</input>
<output>
*Params*OptionsUserId@example重构前:
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'
}
}重构后:
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()
}实现了编译时穷举检查、类型收窄的回调函数,消除了嵌套if语句。
</output>
</example>References
合理性说明表
- type-fest — utility types library
- ts-pattern — exhaustive matching
- TypeScript handbook utility types
来自RED基准记录,其中没有此Skill的Agent会在压力下跳过规则。未来的Agent:在找借口之前,先认清自己的模式。
| 被跳过的规则 | 原话借口 | 错误原因 |
|---|---|---|
对≥2个参数的函数使用 | "我5分钟后要开会,直接写字符串参数就行——之后再重构" | "之后"永远不会到来。位置参数在调用时会被无声地交换(比如email、name、roleId的顺序),而接口的成本只是一段文本,在签名第一次变更时就能收回成本。 |
| 每个导出函数都添加JSDoc | "改动很小,所以我跳过了JSDoc——函数名称已经自解释了" | 名称描述的是做什么,而非为什么。下一个读者(或模型)会失去上下文意图——而导出项是API接口,所以这里的文档价值最高。合并前就添加JSDoc,不要事后补。 |
对ID使用品牌类型( | "它们本质上都是字符串,品牌类型对于一个获取函数来说有点过度设计了" | "本质上"是一种合理化借口。将 |
对≥3个分支使用 | "现有的 | 当添加新变体时, |
绝对不要使用 | "这是内部/工具代码,用 | |
—
参考资料
—
- type-fest —— 工具类型库
- ts-pattern —— 穷举匹配库
- TypeScript手册:工具类型