Claude-skill-registry cleanTypes
TypeScript type best practices - proper typing patterns, avoiding type complexity, meaningful type annotations
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/cleanTypes" ~/.claude/skills/majiayu000-claude-skill-registry-cleantypes && rm -rf "$T"
skills/data/cleanTypes/SKILL.mdTypeScript Type Best Practices
1. Implicit is Better Than Explicit
Rule: If TypeScript can infer the type, don't annotate it.
TypeScript has powerful type inference. Let the compiler do the work.
Remove Return Type Annotations
Bad:
const extractExtension = (filename: string): string => filename.replace(/.*symlink/, '') function isSymlink(filePath: string): boolean { try { return fs.lstatSync(filePath).isSymbolicLink() } catch { return false } } function buildSymlinkPlan(): SymlinkPlan[] { return symlinkFiles.map(src => ({ from: src, to: transformPath(src) })) }
Good:
const extractExtension = (filename: string) => filename.replace(/.*symlink/, '') function isSymlink(filePath: string) { try { return fs.lstatSync(filePath).isSymbolicLink() } catch { return false } } function buildSymlinkPlan() { return symlinkFiles.map(src => ({ from: src, to: transformPath(src) })) }
Why:
returns.replace()
- TypeScript knows thisstring
is clearlyreturn true/falseboolean
with object literal - TypeScript infers the array type.map()
Use .map() Instead of Imperative Loops
Bad:
function buildSymlinkPlan(): SymlinkPlan[] { const plan: SymlinkPlan[] = [] for (const src of symlinkFiles) { plan.push({ from: src, to: transformPath(src) }) } return plan }
Good:
function buildSymlinkPlan() { return symlinkFiles.map(src => ({ from: src, to: transformPath(src) })) }
Why:
- No need for explicit array type annotation
- No need for return type annotation
- More declarative, less noise
- Compiler infers everything correctly
Remove Variable Type Annotations
Bad:
interface Logger { info: (msg: string) => void success: (msg: string) => void warn: (msg: string) => void error: (msg: string) => void } const log: Logger = { info: createLogFunction('info'), success: createLogFunction('success'), warn: createLogFunction('warn'), error: createLogFunction('error') }
Good:
const log = { info: createLogFunction('info'), success: createLogFunction('success'), warn: createLogFunction('warn'), error: createLogFunction('error') }
Why:
- TypeScript infers the shape from the object literal
- If you remove the annotation and the interface becomes unused, delete it
- Less noise, same type safety
When to Keep Type Annotations
Do annotate:
- Function parameters - Always annotate (TypeScript can't infer these)
- At the source - Type variables early in the chain to help inference flow
- Public APIs - Types are documentation for exported functions
- When inference fails - If compiler infers
, you MUST declare explicitlyany
Example - Type at the source to enable inference:
// Bad: No type annotation, chain can't infer properly function findFiles(rootDir: string) { const output = execSync(`find . -name '*.txt'`, { cwd: rootDir, encoding: 'utf-8' }) return output.trim().split('\n').filter(line => line.length > 0) } // Compiler may not infer that output is string, causing downstream issues // Good: Type the source variable, inference flows through the chain function findFiles(rootDir: string) { const output: string = execSync(`find . -name '*.txt'`, { cwd: rootDir, encoding: 'utf-8' }) return output.trim().split('\n').filter(line => line.length > 0) } // Now .trim() knows it's working with string, .split() returns string[], etc.
Don't annotate:
- Obvious return types - String methods, boolean literals, void functions
- Intermediate transformations - Once you've typed the source, let inference flow
- Collection operations -
,.map()
preserve and transform types correctly.filter()
Benefits
- Less code - Fewer type annotations to write and maintain
- Easier refactoring - Change implementation, types update automatically
- Better signal-to-noise - Annotations that remain are truly meaningful
- Finds dead code - Unused interfaces become obvious when you stop annotating
2. Type Aliases for Busy Function Signatures
Rule: Only use type aliases when the function signature gets "busy" with type noise.
Simple Signatures - Keep Inline
Good:
function isSymlink(filePath: string) { return fs.lstatSync(filePath).isSymbolicLink() } function findSymlinkFiles(rootDir: string): string[] { const output = execSync('find . -name "*.txt"', { cwd: rootDir }) return output.trim().split('\n') }
These are clean - minimal noise, easy to read.
Busy Signatures - Use Type Alias
Bad:
function processFiles( rootDir: string, filter: (file: string, stats: fs.Stats) => boolean, transform: (content: string, path: string) => Promise<string>, onError: (error: Error, file: string) => void ): Promise<ProcessResult[]> { // implementation }
Good:
type FileFilter = (file: string, stats: fs.Stats) => boolean type FileTransform = (content: string, path: string) => Promise<string> type ErrorHandler = (error: Error, file: string) => void type ProcessFiles = ( rootDir: string, filter: FileFilter, transform: FileTransform, onError: ErrorHandler ) => Promise<ProcessResult[]> const processFiles: ProcessFiles = (rootDir, filter, transform, onError) => { // implementation }
Why: The signature is now scannable - you see the parameter names clearly, and can check the type alias definitions separately if needed.
When to Use Type Aliases
Use type aliases when:
- Function has 4+ parameters
- Parameters have complex types (callbacks, generic types)
- The same signature is used multiple times
- The inline signature makes the function definition hard to scan
Don't use type aliases when:
- Function has 1-3 simple parameters
- Types are obvious (string, number, boolean)
- Compiler can infer the return type
- The inline signature is still readable
3. Accept Nullable Types, Handle Explicitly
Rule: Accept
T | null in your type signatures, handle the null case explicitly with clear failure semantics.
Bad:
// Filter nulls early, pass only valid values function buildPlan() { return files.map(transformPath).filter(path => path !== null) } function executePlan(plan: string[]) { // Silent assumption: all paths are valid return plan.map(path => createLink(path)) }
Good:
// Accept nullable type, handle explicitly function buildPlan() { return files.map(file => ({ from: file, to: transformPath(file) // Returns string | null })) } function executePlan(plan: Array<{from: string, to: string | null}>) { return plan.map(({ from, to }) => safeLink(from, to)) } function safeLink(src: string, dest: string | null) { if (!dest) { // Explicit failure with error result return { from: src, to: '<unparseable>', success: false } } // Continue with valid dest }
Why:
- Explicit over silent - Failures are visible in results, not hidden by filtering
- Push responsibility down - Let
decide what to do with nulls (it's about safety!)safeLink - Better error tracking - You can count and report failures, not silently skip them
4. Export Discipline
Rule: Only export what's actually used by other modules. Everything else is internal implementation.
Bad:
// links.ts export { setupSymlinks, buildSymlinkPlan, safeLink, transformPath } export type { SymlinkPlan, LinkResult }
Check if these are imported anywhere:
$ rg "import.*buildSymlinkPlan" # No results $ rg "import.*safeLink" # No results
Good:
// links.ts - Only export the public API export { setupSymlinks } export type { LinkResult } // Used by consumers
Why:
- Clear API surface - What's exported is your public interface
- Easier refactoring - Internal functions can be renamed/removed freely
- Finds dead code - Unused exports highlight unnecessary abstractions
Process:
- After writing a module, search for usages:
rg "import.*functionName" - Remove exports that have zero imports
- Keep only: (a) main public API functions, (b) types used by consumers
References
- TypeScript Handbook: https://www.typescriptlang.org/docs/handbook/
- Effective TypeScript by Dan Vanderkam