Target: public SDKs (libraries consumed by external developers). Audience: Claude Code agents performing greenfield work, refactors, and reviews. Treat every rule as a hard rule unless explicitly marked SHOULD. Reject diffs that violate MUST rules.
- MUST enable
"strict": true, plusnoUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride,noFallthroughCasesInSwitch,noPropertyAccessFromIndexSignature,useUnknownInCatchVariables,isolatedModules,verbatimModuleSyntax. - MUST target
ES2022minimum; emit ESM as primary, dual-publish CJS viaexportsconditions only when consumers demand it. - MUST ship
.d.tstypes alongside JS; never publish source.ts. - MUST NOT use
any. Useunknownand narrow. Ifanyis truly required, isolate it behind a single audited helper with a comment explaining why. - MUST NOT use
// @ts-ignore. Use// @ts-expect-error <reason>so the suppression breaks the build when no longer needed. - MUST NOT use
enum,namespace, parameter properties on classes, decorators (unless framework-required), ordefaultexports. - MUST use named exports only — they refactor cleanly, tree-shake, and prevent accidental rename drift across consumers.
- Prefer discriminated unions over class hierarchies and over
optional-field grab-bags. Tag with a literal
kindortypefield. - Use branded types for IDs and opaque values:
type UserId = string & { readonly __brand: "UserId" }. Construct via a single validator; never cast at call sites. - Use
readonlyon every field, array, tuple, and map by default. Mutability is opt-in, not opt-out.ReadonlyArray<T>overT[]in signatures. - Use
as constfor literal data; derive types withtypeof X[number]. - Replace
enumwith string literal unions oras constobjects:export const LogLevel = { Debug: "debug", Info: "info", Warn: "warn", Error: "error", } as const; export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
- Use
satisfiesto validate shapes without widening; do not annotate whensatisfiesis correct. - Reach for utility types (
Pick,Omit,Extract,Exclude,NonNullable,Awaited) before hand-rolling. Build small named helpers (type NonEmpty<T> = [T, ...T[]]) rather than inlining clever generics. - Generics: constrain every parameter (
<T extends Foo>); single-letter names only when role is obvious (T,K,V,E); otherwise use descriptive names (TPayload,TResponse). - Function overloads: only when the relationship between input and output types cannot be expressed in a single signature. Otherwise use conditional types or union returns.
- One barrel file:
src/index.tsis the only public entry point. Everything else is internal. Enforce viapackage.json"exports"field — no"./*"wildcards. - Every public symbol has an explicit return type annotation. Internal helpers may infer. This stabilizes the wire format of the SDK against accidental type widening.
- Public types are nominal where it matters: brand IDs, tokens,
URLs, dates. Avoid leaking
Record<string, unknown>— define a shape or acceptunknownand validate. - No leaking internal types: anything not in
index.tsis not part of the contract. Mark internal helpers@internalin TSDoc; configure the API extractor to strip them from.d.ts. - Options objects for any function with >2 parameters or any optional parameter. Required first, optional bag last. Never positional booleans.
- No default arguments in public APIs that change behavior. Defaults are fine for ergonomics (timeouts, retries); document every default in TSDoc.
- Stability: every breaking change to a public symbol = major
version bump. Internal refactors must not change
.d.tsoutput. Diff.d.tsin CI.
- MUST subclass
Error, setname, and forwardcause:export class RateLimitError extends Error { readonly name = "RateLimitError"; constructor( message: string, readonly retryAfterMs: number, options?: { cause?: unknown }, ) { super(message, options); } }
- MUST expose every error class from the public barrel so consumers
can
instanceof-check. Provide a discriminated union typeSdkError = RateLimitError | NetworkError | ValidationError | .... - SHOULD prefer returning result objects for expected, recoverable outcomes (parse, validate, find); throw for programmer error and unrecoverable failures. Do not mix both for the same function.
- MUST NOT throw plain strings, numbers, objects, or
Errorwithout a specific subclass. - MUST preserve
causechains across layers — never swallow the original error. - MUST NOT log inside library code. Surface errors; let consumers decide. Provide an optional logger hook if observability is needed.
- MUST accept
AbortSignalon every async public function that performs I/O. Honor it; throwDOMException("...", "AbortError")or a typedAbortErrorsubclass. - MUST NOT create floating promises. Enable
@typescript-eslint/no-floating-promises. Eitherawait, return, or explicitlyvoidwith a comment. - MUST NOT swallow rejections. No empty
.catch(() => {}). - Prefer
async/awaitover.then. Mix only when composing with legacy promise utilities. - No
asyncconstructors — use static factory methods returningPromise<T>. - Bound concurrency explicitly (
p-limit, semaphore, queue). Never fire-and-forgetPromise.allover user-supplied arrays. - Time is injected, never imported: pass
now()andsetTimeoutthrough an options object or a clock abstraction for testability.
- One concept per file. A file exports one class, one factory, or one tightly related cluster of pure functions. If you can't name the file in two words, split it.
- No circular imports. Enforce with
eslint-plugin-importormadgein CI. - Side-effect-free modules: top-level code does nothing but
declare. No
console.log, no fetch, no mutation. Enables tree-shaking and predictable load order. Mark"sideEffects": falseinpackage.json. - Internal layout:
src/ index.ts # public barrel ONLY client.ts # entry class/factory errors.ts # error subclasses types.ts # public type aliases internal/ # everything not exported http.ts retry.ts ... - Test files live next to source as
*.test.ts, not in a separate tree. Co-location speeds refactors.
- Files:
kebab-case.ts. Test files:kebab-case.test.ts. - Types/interfaces/classes:
PascalCase. NoIprefix on interfaces. NoTprefix on types. - Functions/variables:
camelCase. Constants that are truly module-level immutable primitives may beSCREAMING_SNAKE. - Booleans: prefixed
is,has,should,can,did. - Async functions: no
Asyncsuffix; the return type says it. - Avoid abbreviations in public APIs (
confignotcfg,requestnotreq). Internal hot loops may abbreviate. - No Hungarian notation. No type info in names (
userArr,nameStr).
- Every public export has a TSDoc block: one-sentence summary,
@paramfor each parameter,@returns,@throwsfor each thrown error class,@examplefor non-trivial usage,@seefor related symbols. - Mark stability:
@public,@beta,@alpha,@internal,@deprecated <replacement>. - Never duplicate type info in prose ("the string name of the user"). Document meaning, invariants, side effects.
- Examples must compile. Run them through
tsdoreslint-plugin-tsdocin CI.
- Zero runtime dependencies is the goal; every dep is a liability for consumers (audit surface, bundle size, version conflicts).
- Peer-dep anything a consumer is likely to already have
(framework runtimes,
react,zod, etc.). Specify wide ranges; pin in your own lockfile only. - Never bundle deps into your published artifact unless they are trivially small and you own them.
- No polyfills shipped in the SDK. Document required runtime.
- Use
devDependenciesaggressively; consumers never see them. type-festis acceptable as a dev-dep for type plumbing; don't re-export its types.
- MUST ship ESM (
"type": "module"). Provide CJS only if measurable demand exists, viaexportsconditions. - MUST populate
package.json"exports"withimport,require(if dual),types, anddefault. Do not rely on"main"/"types"alone for modern resolvers. - MUST ship sourcemaps (
.js.map) and declaration maps (.d.ts.map); includesrc/in the published tarball so go-to-definition lands on TS, not generated JS. - MUST set
"sideEffects": false(or list exactly which files have side effects). - MUST include
engines.nodeand document supported runtimes (Node, Bun, Deno, browsers, Workers). - MUST add
provenance: trueon publish; sign artifacts. - Use
tsup,unbuild, ortscdirectly. Avoid Webpack for library output. Verify output with@arethetypeswrong/cliin CI.
- Vitest preferred (fast, ESM-native, TS-native). Jest acceptable for legacy.
- Type tests with
expectTypeOf/tsdfor every public generic. Type regressions are silent — test them. - No mocking of your own modules. Inject dependencies. If you must mock, it's a design smell.
- Snapshot tests only for stable, human-readable output (formatted strings, generated code). Never for objects.
- Public API smoke test: import only from the barrel; verify every exported symbol exists and has the documented shape.
- Coverage is a smell detector, not a goal. Target paths, not percentages.
These are enforced limits, not aspirations. Failing CI is the correct response.
| Metric | Max | Aspire |
|---|---|---|
| Line length (chars) | 100 | 80 |
| Function body (lines) | 40 | ≤ 20 |
| File length (lines) | 300 | ≤ 150 |
| Function parameters | 3 | ≤ 2 (use options object) |
| Cyclomatic complexity | 10 | ≤ 5 |
| Nesting depth | 3 | ≤ 2 |
| Generic type parameters | 3 | ≤ 2 |
| Public exports per barrel | 50 | split if more |
- One function does one thing. If you need "and" to name it, split it.
- Early returns / guard clauses over nested conditionals. Flatten by inverting the predicate.
- Extract on the second occurrence, not the third. Duplication in a public SDK calcifies fast.
- No clever code. If a reviewer must run it in their head to follow it, rewrite. Cleverness is a tax on every future reader, including Claude.
- Delete code aggressively. Dead branches, "just in case" parameters, commented-out blocks — gone. Git remembers.
- Trailing commas everywhere.
- Single quotes for strings; backticks only when interpolating.
- Semicolons on.
- Imports sorted: node built-ins → external → internal → relative;
blank line between groups.
import typefor type-only. - No re-exports of internal modules from the barrel — only the curated public surface.
constoverlet;letovervar(nevervar).- Arrow functions for callbacks; named
functiondeclarations for module-level helpers (hoisting + stack-trace readability). - Object shorthand, spread over
Object.assign, destructuring with defaults at the destructure site. - No
elseafterreturn. No ternary nesting. No bit-tricks in application code.
- Need a type for a finite set? →
as constobject + union derive. - Need an ID? → branded string with a validator.
- Need an error? → subclass with
name,cause, and an exported class. - Need an optional? →
T | undefined(withexactOptionalPropertyTypes); avoidT | nullunless interop forces it. Pick one and stick to it. - Need a callback? → name it; type it; document it; never inline a complex type in a signature.
- Need configuration? → options object, required first, optional bag last, every default documented.
- Need to share code between SDK and app? → it does not belong in the SDK. Extract a third package.
{ "rules": { "max-len": [ "error", { "code": 100, "ignoreUrls": true, "ignoreStrings": true }, ], "max-lines": [ "error", { "max": 300, "skipBlankLines": true, "skipComments": true }, ], "max-lines-per-function": ["error", { "max": 40, "skipBlankLines": true }], "max-params": ["error", 3], "max-depth": ["error", 3], "complexity": ["error", 10], "no-console": "error", "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/no-floating-promises": "error", "@typescript-eslint/no-misused-promises": "error", "@typescript-eslint/explicit-module-boundary-types": "error", "@typescript-eslint/consistent-type-imports": [ "error", { "fixStyle": "inline-type-imports" }, ], "@typescript-eslint/consistent-type-exports": "error", "@typescript-eslint/no-non-null-assertion": "error", "@typescript-eslint/prefer-readonly": "error", "@typescript-eslint/switch-exhaustiveness-check": "error", "import/no-default-export": "error", "import/no-cycle": "error", }, }