ts-best-practices

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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.

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

ElementConventionExample
Files
kebab-case.ts
user-service.ts
,
auth-types.ts
Variables
camelCase
userId
,
isAuthenticated
Functions
camelCase
(verbs)
createUser
,
parseHeaders
Types/interfaces
PascalCase
User
,
CreateUserParams
Constants
SCREAMING_SNAKE_CASE
MAX_RETRIES = 3
Const objects
SCREAMING_SNAKE_CASE
keys with
as const
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>
  • 用户想要函数式风格重构 → 使用
    ts-best-practices-functional
  • 用户正在编写框架组件(React、Vue、Svelte)—— 这些框架有不同的约定
  • 用户正在使用纯JavaScript而非TypeScript
  • 用户正在调试运行时错误(最佳实践关注代码形态,而非调试工具)

File structure

核心约定

命名规范

Top-to-bottom order in every
.ts
file:
  1. Imports (grouped: node built-ins, external, internal
    @pkg/*
    , relative — blank line between groups)
  2. Types (interfaces, type aliases)
  3. Constants
  4. Exported functions (the public API — first, so readers see it without scrolling)
  5. Private functions (
    function
    declarations are hoisted, so position doesn't matter)
No banner comments (
// === HELPERS ===
). The structure speaks for itself.
元素约定示例
文件
kebab-case.ts
user-service.ts
,
auth-types.ts
变量
camelCase
userId
,
isAuthenticated
函数
camelCase
(动词开头)
createUser
,
parseHeaders
类型/接口
PascalCase
User
,
CreateUserParams
常量
SCREAMING_SNAKE_CASE
MAX_RETRIES = 3
常量对象键使用
SCREAMING_SNAKE_CASE
并搭配
as const
GITHUB_EVENTS = { PUSH: 'push' } as const
对象属性:当属性形成逻辑分组时,优先使用嵌套结构;独立值则使用扁平结构。
<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
Params
/
Options
/
Args
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>
SuffixUse case
*Params
Required input parameters
*Options
Optional configuration
*Args
Function arguments (less common)
每个
.ts
文件从上到下的顺序:
  1. 导入语句(分组:Node.js内置模块、外部模块、内部
    @pkg/*
    模块、相对路径模块 —— 组之间用空行分隔)
  2. 类型定义(接口、类型别名)
  3. 常量
  4. 导出函数(公开API —— 放在最前面,让读者无需滚动就能看到)
  5. 私有函数(
    function
    声明会被提升,所以位置不影响)
不要使用横幅注释(如
// === HELPERS ===
)。文件结构本身就能说明问题。

JSDoc

函数参数

Every exported function needs JSDoc with
@param
,
@returns
, and
@example
(when useful). Document the why, not the what. Skip only in test files.
ts
/**
 * 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
@private
:
ts
/**
 * Extracts the display name from a user record.
 *
 * @private
 */
function getDisplayName(user: User): string {
  return user.displayName ?? user.email
}
当函数有2个及以上相关参数时,使用对象参数。定义一个后缀为
Params
/
Options
/
Args
的接口,并在函数签名中解构。
<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 { // 调用时容易混淆email和name的顺序 } </bad>
后缀使用场景
*Params
必填输入参数
*Options
可选配置参数
*Args
函数参数(较少使用)

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
userId
and
orgId
):
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
}
as const
for literal types:
ts
const STATUSES = ['pending', 'active', 'completed'] as const
type Status = (typeof STATUSES)[number] // "pending" | "active" | "completed"
type-fest
for utility types not in stdlib:
SetRequired
,
SetOptional
,
PartialDeep
,
ReadonlyDeep
,
Except
,
Simplify
.
Never use
any
— use
unknown
and narrow with type guards.
每个导出函数都需要带有
@param
@returns
@example
(有用时)的JSDoc。要说明原因,而非内容。仅在测试文件中可以省略。
ts
/**
 * 将原始Webhook头解析为类型化结构。
 *
 * @param params - 原始头信息和提供商标识符
 * @returns 解析后的Webhook头或解析错误
 *
 * @example
 * ```ts
 * const result = parseWebhookHeaders({ headers, provider: 'github' })
 * ```
 */
export function parseWebhookHeaders(params: ParseHeadersParams) {
  // ...
}
私有(非导出)函数也需要JSDoc,并添加
@private
标记:
ts
/**
 * 从用户记录中提取显示名称。
 *
 * @private
 */
function getDisplayName(user: User): string {
  return user.displayName ?? user.email
}

Conditionals

类型规范

ScenarioUseWhy
Early return / guard
if
Cleaner guard clauses
Simple A or BSingle inline ternary or a tiny
if/else
Lightweight, no library churn
3+ branches
ts-pattern
's
match()
Exhaustive, readable
Discriminated unions
ts-pattern
with
.exhaustive()
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>
可区分联合用于变体类型:
ts
type AuthStrategy =
  | { strategy: 'connection'; connectionId: string }
  | { strategy: 'integration'; integrationId: string }
  | { strategy: 'user'; userId: string; token: string }
品牌类型用于ID(防止意外混淆
userId
orgId
):
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
}
**
as const
**用于字面量类型:
ts
const STATUSES = ['pending', 'active', 'completed'] as const
type Status = (typeof STATUSES)[number] // "pending" | "active" | "completed"
**
type-fest
**用于标准库中没有的工具类型:
SetRequired
,
SetOptional
,
PartialDeep
,
ReadonlyDeep
,
Except
,
Simplify
绝对不要使用
any
—— 使用
unknown
并通过类型守卫进行收窄。

Anti-patterns

条件判断

AvoidUse instead
any
unknown
+ narrow with type guard
Ternaries (esp. nested)
call()
for A/B,
match()
for 3+
switch
statements
match().with(...).exhaustive()
Banner comments (
// === HELPERS ===
)
Let file structure speak
Arrow functions for private helpers
function
declarations (hoisted)
Type assertion (
as Foo
) without validation
Type guard or schema parse
Concatenated property names (
clerkOrgId
)
Nested objects (
clerk.orgId
)
5+ positional paramsRefactor to a
*Params
object
场景使用方式原因
提前返回/守卫
if
语句
更简洁的守卫逻辑
简单二选一单行三元表达式或简短的
if/else
轻量,无需引入额外库
3个及以上分支
ts-pattern
match()
方法
穷举性,可读性强
可区分联合
ts-pattern
搭配
.exhaustive()
方法
编译时安全检查
<good> import { match } from 'ts-pattern'
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:
*Params
+
*Options
split, branded
UserId
, JSDoc with
@example
, 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>
需避免的写法替代方案
any
类型
unknown
+ 类型守卫收窄
三元表达式(尤其是嵌套的)二选一用
call()
,三分支及以上用
match()
switch
语句
match().with(...).exhaustive()
横幅注释(如
// === HELPERS ===
依靠文件结构来区分
私有辅助函数使用箭头函数
function
声明(会被提升)
无验证的类型断言(
as Foo
类型守卫或模式解析
拼接属性名(如
clerkOrgId
嵌套对象(如
clerk.orgId
5个及以上位置参数重构为
*Params
对象

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 ruleVerbatim excuseWhy it's wrong
Use
*Params
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 (
UserId
,
OrgId
)
"they're both strings basically, branded types feel over-engineered for a fetch""Basically" is the rationalization. Real bugs from swapping
userId
and
orgId
ship to prod regularly; the type is one line and free at runtime.
ts-pattern
match().exhaustive()
for ≥3 branches
"the existing
if/else
works, ts-pattern is overkill — my reviewer is gonna complain about churn"
The reviewer complains when a new variant is added and the
else
branch silently swallows it in prod.
.exhaustive()
is a compile-time tripwire — not a refactor for refactor's sake.
Never use
any
"this is internal/utility code,
any
is fine —
unknown
is more typing for the same thing"
any
opts out of the type checker;
unknown
opts in and forces a guard. They're not the same thing. Internal code outlives the "internal" framing.
<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应用了以下规范:
*Params
+
*Options
分离、品牌化
UserId
、带
@example
的JSDoc、导出优先的顺序、无横幅注释、提前返回式缓存检查、底部的私有辅助函数。
</output> </example> <example> <input>"重构这段处理部署状态的嵌套if/else代码"</input> <output>
重构前:
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

合理性说明表

来自RED基准记录,其中没有此Skill的Agent会在压力下跳过规则。未来的Agent:在找借口之前,先认清自己的模式。
被跳过的规则原话借口错误原因
对≥2个参数的函数使用
*Params
对象
"我5分钟后要开会,直接写字符串参数就行——之后再重构""之后"永远不会到来。位置参数在调用时会被无声地交换(比如email、name、roleId的顺序),而接口的成本只是一段文本,在签名第一次变更时就能收回成本。
每个导出函数都添加JSDoc"改动很小,所以我跳过了JSDoc——函数名称已经自解释了"名称描述的是做什么,而非为什么。下一个读者(或模型)会失去上下文意图——而导出项是API接口,所以这里的文档价值最高。合并前就添加JSDoc,不要事后补。
对ID使用品牌类型(
UserId
OrgId
"它们本质上都是字符串,品牌类型对于一个获取函数来说有点过度设计了""本质上"是一种合理化借口。将
userId
orgId
混淆的真实bug经常被部署到生产环境;品牌类型只需一行代码,运行时无额外开销。
对≥3个分支使用
ts-pattern
match().exhaustive()
"现有的
if/else
能用,ts-pattern有点多余——我的评审会抱怨代码改动太大"
当添加新变体时,
else
分支会在生产环境中无声地吞掉它,这时评审才会抱怨。
.exhaustive()
是编译时的安全防线——不是为了重构而重构。
绝对不要使用
any
"这是内部/工具代码,用
any
没问题——
unknown
只是多写了代码,效果一样"
any
会完全退出类型检查;
unknown
会进入类型检查并强制使用守卫。它们完全不同。内部代码的生命周期往往比"内部"的定位更长。

参考资料