skills/typescript/SKILL.md
TypeScript-specific coding conventions and type system patterns. Always load this skill when writing or reviewing TypeScript code.
npx skillsauth add alexanderguy/skills typescriptInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
TypeScript-specific guidelines for type safety and code organization.
import type for type-only imports{ cause } when re-throwing errorscreate* prefixnull from handlers when request doesn't matchconsole.logany type (use unknown and narrow)as Type) - they indicate interface problemsx!) - they hide nullability bugs| Type | Convention | Example |
| ------------------- | --------------------------------- | ------------------------------- |
| Regular modules | Lowercase, hyphens for multi-word | token-payment.ts, server.ts |
| Single-word modules | Lowercase | cache.ts, common.ts |
| Test files | {name}.test.ts | cache.test.ts |
| Pattern | Use Case | Example |
| ----------------- | ------------------------ | --------------------------------- |
| PascalCase | Interfaces, type aliases | PaymentHandler, RequestConfig |
| *Args / *Opts | Function arguments | CreateHandlerOpts |
| *Response | API responses | SettleResponse |
| *Info | Data structures | ChainInfo, TokenInfo |
| *Handler | Handler interfaces | PaymentHandler |
| Pattern | Use Case | Example |
| ----------- | ------------------------------ | ----------------------------------- |
| camelCase | All functions | handleRequest |
| create* | Factory functions | createHandler, createClient |
| is* | Boolean predicates | isValidationError, isKnownType |
| get* | Retrieval without side effects | getBalance, getConfig |
| lookup* | Search/lookup operations | lookupToken, lookupNetwork |
| generate* | Builder/generator functions | generateMatcher, generateConfig |
| handle* | Event/request handlers | handleSettle, handleVerify |
| Pattern | Use Case | Example |
| ---------------------- | --------------------------- | -------------------------------- |
| camelCase | Regular variables | paymentResponse, blockNumber |
| SCREAMING_SNAKE_CASE | Constants, environment vars | API_BASE_URL, MAX_RETRIES |
| _ prefix | Unused parameters | _ctx, _unused |
Acronyms are not words. Do not conform them to camelCase or PascalCase word boundaries. Preserve the acronym's natural capitalization:
// Good - types preserve acronyms
type JSONSchema = { ... }
type HTTPResponse = { ... }
type APIClient = { ... }
type XMLParser = { ... }
// Bad - don't camelCase acronyms in types
type JsonSchema = { ... } // Should be JSONSchema
type HttpResponse = { ... } // Should be HTTPResponse
type ApiClient = { ... } // Should be APIClient
// Good - functions and variables preserve acronyms too
getURLFromRequest
requestURL
parseHTTPHeaders
parseJSON
// Bad
getUrlFromRequest // Should be getURLFromRequest
requestUrl // Should be requestURL
parseJson // Should be parseJSON
Common acronyms: URL, HTTP, HTTPS, JSON, API, RPC, HTML, XML
Note: "ID" is an abbreviation, not an acronym, so use standard camelCase: userId, requestId, getId().
Use a validation library (e.g., arktype, zod, typebox) for runtime type validation. Define the validator and TypeScript type together:
import { type } from "arktype";
// Define runtime validator
export const PaymentRequest = type({
scheme: "string",
network: "string",
amount: "string.numeric",
resource: "string.url",
});
// Derive TypeScript type from validator
export type PaymentRequest = typeof PaymentRequest.infer;
If no existing validation library is installed, install arktype and use it.
This pattern should be used for all external data: API responses from fetch, file system reads, environment variables, user input, and third-party API responses.
Create type guards using validation functions:
export function isAddress(maybe: unknown): maybe is Address {
return !isValidationError(Address(maybe));
}
export function isKnownNetwork(n: string): n is KnownNetwork {
return knownNetworks.includes(n as KnownNetwork);
}
type: Use for data structures, unions, and validator-derived typesinterface: Use for behavioral contracts (objects with methods)// Type for data structure
export type RequestContext = {
request: RequestInfo | URL;
};
// Interface for behavioral contract
export interface PaymentHandler {
getSupported?: () => Promise<SupportedKind>[];
handleSettle: (requirements, payment) => Promise<SettleResponse | null>;
}
Use as const for exhaustive literal types:
const PaymentMode = {
Direct: "direct",
Deferred: "deferred",
} as const;
type PaymentMode = (typeof PaymentMode)[keyof typeof PaymentMode];
// TypeScript ensures all cases handled in switch
switch (mode) {
case PaymentMode.Direct:
// ...
break;
case PaymentMode.Deferred:
// ...
break;
}
Use import type for type-only imports:
import type { PaymentRequest } from "./types";
import type { Hex, Account } from "viem";
// Mixed imports
import {
type Transaction,
createTransaction, // value import
} from "./transactions";
Let TypeScript infer types when obvious:
// Good - return type is obvious
const createHandler = async (network: string) => {
const config = { network, enabled: true };
return {
getConfig: () => config,
isEnabled: () => config.enabled,
};
};
// Unnecessary - the return type is obvious
const createHandler = async (network: string): Promise<{
getConfig: () => { network: string; enabled: boolean };
isEnabled: () => boolean;
}> => { ... };
When to add explicit types:
When NOT to add explicit types:
any and Type AssertionsType assertions (as Type) only affect compile-time types. They provide zero runtime safety. A type assertion tells TypeScript "trust me, this is the shape" but does nothing at runtime.
This is especially critical for external data. Data from fetch, the filesystem, environment variables, user input, and third-party APIs always needs runtime validation because:
Use unknown instead of any when the type is truly unknown, then narrow with validation:
// Bad
function processData(data: any) {
return data.value;
}
// Good
function processData(data: unknown) {
const validated = MyDataType(data);
if (isValidationError(validated)) {
throw new Error(`Invalid data: ${validated.summary}`);
}
return validated.value;
}
Type assertions bypass type checking and often indicate interface problems. Prefer runtime validation:
// Bad
const data = (await response.json()) as UserData;
// Good
const raw = await response.json();
const data = UserData(raw);
if (isValidationError(data)) {
throw new Error(`Invalid response: ${data.summary}`);
}
The non-null assertion operator (x!) has the same problem as as Type: it's a compile-time lie. It tells TypeScript "trust me, this isn't null or undefined" when the compiler thinks it could be. If the compiler thinks a value might be null, there's usually a reason.
Instead of silencing the compiler, restructure the code so the value is provably non-null:
// Bad - hiding a potential bug
const user = users.find(u => u.id === id)!;
processUser(user);
// Good - handle the null case
const user = users.find(u => u.id === id);
if (!user) {
throw new Error(`User not found: ${id}`);
}
processUser(user);
// Bad - asserting map result exists
const handler = handlers.get(name)!;
// Good - check and provide a meaningful error
const handler = handlers.get(name);
if (!handler) {
throw new Error(`No handler registered for: ${name}`);
}
If you find yourself reaching for !, it means one of:
T | null when it shouldn't (fix the upstream function)Prefer generic type parameters with constraints over index signatures:
// Bad - index signature (too permissive)
export interface LoggingBackend {
configureApp(args: {
level: LogLevel;
[key: string]: unknown;
}): Promise<void>;
}
// Good - generic with constraint (type-safe)
export type BaseConfigArgs = { level: LogLevel };
export interface LoggingBackend<TConfig extends BaseConfigArgs = BaseConfigArgs> {
configureApp(args: TConfig): Promise<void>;
}
Use index.ts files to re-export from modules:
// packages/types/src/index.ts
// Namespaced exports for grouped functionality
export * as payments from "./payments";
export * as client from "./client";
// Flat exports for utilities
export * from "./validation";
export * from "./helpers";
// Good
export function createMiddleware(args: CreateMiddlewareArgs) { ... }
export const MAX_RETRIES = 3;
// Avoid
export default function createMiddleware(args: CreateMiddlewareArgs) { ... }
Order imports by category:
// External libraries
import { type } from "arktype";
import { Hono } from "hono";
// Internal packages
import { isValidationError } from "@myorg/types";
import type { Handler } from "@myorg/types/handler";
// Relative imports
import { isValidTransaction } from "./verify";
import { logger } from "./logger";
Omit file extensions in import paths when the module resolver can infer them:
// Good - no extension needed
import { createHandler } from "./handler";
import type { Config } from "../types";
// Bad - unnecessary extension
import { createHandler } from "./handler.ts";
import type { Config } from "../types.ts";
Note: Some environments (like Deno or Node.js with "type": "module") require explicit extensions. Follow project conventions when extensions are mandated by the runtime.
Dynamic import() expressions should be used sparingly. They exist for genuinely dynamic scenarios where the module to load is not known at authoring time (e.g., plugin systems where the module path is constructed from a variable) or where a module must be conditionally loaded at runtime (e.g., optional dependencies that may not be installed).
If you know which module you need, use a static import at the top of the file. Do not use await import() inline next to your code change because it is convenient — that is a static dependency with worse type safety and unnecessary indirection. Add the import statement to the top of the file where it belongs.
// Bad - lazy inline import of a known module
const { createHandler } = await import("./handler");
// Good - static import at the top of the file
import { createHandler } from "./handler";
// Good - genuinely dynamic: the module path is not known at authoring time
const plugin = await import(`./plugins/${pluginName}`);
// Good - conditional loading of an optional dependency
let sharp: typeof import("sharp") | undefined;
try {
sharp = await import("sharp");
} catch (err) {
logger.warn("sharp not installed, falling back to basic image handling", { cause: err });
}
Use async factory functions that return objects with async methods:
const createHandler = async (network: string, rpc: RpcClient, config?: HandlerOptions) => {
// Async initialization
const networkInfo = await fetchNetworkInfo(rpc);
// Return object with async methods
return {
getSupported,
handleVerify,
handleSettle,
};
};
Use Promise.all for independent parallel operations:
const [tokenName, tokenVersion] = await Promise.all([
client.readContract({ functionName: "name" }),
client.readContract({ functionName: "version" }),
]);
Use Promise.race for operations that need timeouts:
function timeout(timeoutMs: number, msg?: string) {
return new Promise((_, reject) =>
setTimeout(() => reject(new Error(msg ?? "timed out")), timeoutMs),
);
}
const result = await Promise.race([
fetchData(),
timeout(5000, "fetch timed out"),
]);
Implement retries with exponential backoff:
let attempt = (options.retryCount ?? 2) + 1;
let backoff = options.initialRetryDelay ?? 100;
let response;
do {
response = await makeRequest();
if (response.ok) {
return response;
}
await new Promise((resolve) => setTimeout(resolve, backoff));
backoff *= 2;
} while (--attempt > 0);
Check validation errors before proceeding:
const payload = parsePayload(input);
if (isValidationError(payload)) {
logger.debug(`couldn't validate payload: ${payload.summary}`);
return sendBadRequest();
}
// payload is now typed correctly
Create local helpers for consistent error responses:
const handleSettle = async (requirements, payment) => {
const errorResponse = (msg: string): SettleResponse => {
logger.error(msg);
return {
success: false,
error: msg,
txHash: null,
};
};
if (someConditionFails) {
return errorResponse("Invalid transaction");
}
// ...
};
Use { cause } when re-throwing errors:
try {
transaction = parseTransaction(input);
} catch (cause) {
throw new Error("Failed to parse transaction", { cause });
}
null for "Not My Responsibility"Handlers should return null when a request doesn't match their criteria:
const handleVerify = async (requirements, payment) => {
if (!isMatchingRequirement(requirements)) {
return null; // Let another handler try
}
// Handle the request...
};
Focus test coverage on logic specific to your codebase:
Do not write tests that merely verify functionality provided by external libraries. Trust well-maintained libraries to do their job.
import t from "tap";
await t.test("descriptiveTestName", async (t) => {
// Setup
const cache = new Cache({ capacity: 3 });
// Assertions
t.equal(cache.size, 0);
t.matchOnly(cache.get("key"), undefined);
t.end();
});
Inject time functions for deterministic time-based tests:
let theTime = 0;
const now = () => theTime;
const cache = new Cache({
maxAge: 1000,
now, // Inject time function
});
theTime += 500;
t.matchOnly(cache.get("key"), 42); // Still valid
theTime += 1000;
t.matchOnly(cache.get("key"), undefined); // Expired
Document public APIs with TSDoc:
/**
* Creates a handler for the payment scheme.
*
* @param network - The network identifier (e.g., "mainnet", "testnet")
* @param rpc - RPC client
* @param config - Optional configuration options
* @returns Promise resolving to a Handler
*/
export const createHandler = async (
network: string,
rpc: RpcClient,
config?: HandlerOptions,
): Promise<Handler> => { ... };
development
Perform a code review or pull request review on a branch
development
Write scripts using opsh and its built-in libraries. Load this skill when writing, reviewing, or debugging opsh scripts.
testing
Reshape git history with rebase — edit-in-place to fix an earlier commit, squash/fixup, drop, split, reword, or validate every replayed commit. Load whenever you need to change a commit that is not HEAD, or for any branch-history cleanup before push. Covers driving every editor invocation non-interactively so the rebase runs without a human at the keyboard.
tools
Conduct an iterative multiple-choice interview using AskUserQuestion. Returns the Q&A inline. Use as a utility when a caller needs structured user input on a topic.