From TypeScript Best Practices
Modern TypeScript patterns your AI agent should use. Strict mode, discriminated unions, satisfies operator, const assertions, and type-safe patterns for TypeScript 5.x.
How this skill is triggered — by the user, by Claude, or both
Slash command
/typescript-best-practices:typescript-best-practicesThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this skill when working with TypeScript code. AI agents frequently generate outdated patterns -
Use this skill when working with TypeScript code. AI agents frequently generate outdated patterns -
using any instead of unknown, type assertions instead of satisfies, optional fields instead of
discriminated unions, and missing strict mode options. This skill enforces modern TypeScript 5.x
patterns.
Wrong (agents do this):
{
"compilerOptions": {
"strict": false,
"target": "ES2020"
}
}
Correct:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"target": "ES2022"
}
}
Why: Strict mode catches entire categories of bugs. noUncheckedIndexedAccess prevents unsafe
array/object access. Agents often skip these for "convenience."
Wrong (agents do this):
const config = {
port: 3000,
host: "localhost",
} as Config;
config.port.toFixed(); // No error even if port could be string
Correct:
const config = {
port: 3000,
host: "localhost",
} satisfies Config;
config.port.toFixed(); // TypeScript knows port is number
Why: satisfies validates the type without widening it. as silences the compiler and can hide
bugs. Use satisfies for validation, as only when you genuinely know more than the compiler.
Wrong (agents do this):
interface ApiResponse {
data?: User;
error?: string;
loading?: boolean;
}
Correct:
type ApiResponse =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
Why: Optional fields allow impossible states (data AND error both present). Discriminated unions make each state explicit and exhaustively checkable.
Wrong (agents do this):
const ROUTES = {
home: "/",
about: "/about",
contact: "/contact",
};
// Type: { home: string; about: string; contact: string }
Correct:
const ROUTES = {
home: "/",
about: "/about",
contact: "/contact",
} as const;
// Type: { readonly home: "/"; readonly about: "/about"; readonly contact: "/contact" }
Why: Without as const, TypeScript widens literal types to string. With it, you get exact
literal types and readonly properties.
Wrong (agents do this):
function parseJson(text: string): any {
return JSON.parse(text);
}
const data = parseJson('{"name": "test"}');
data.nonExistent.method(); // No error - runtime crash
Correct:
function parseJson(text: string): unknown {
return JSON.parse(text);
}
const data = parseJson('{"name": "test"}');
if (isUser(data)) {
data.name; // Safe - type narrowed
}
Why: any disables all type checking. unknown forces you to narrow the type before using it,
catching bugs at compile time.
Wrong (agents do this):
function getLocaleMessage(id: string): string { ... }
Correct:
type Locale = 'en' | 'ja' | 'pt';
type MessageKey = 'welcome' | 'goodbye';
type LocaleMessageId = `${Locale}_${MessageKey}`;
function getLocaleMessage(id: LocaleMessageId): string { ... }
Why: Template literal types create precise string patterns from unions. The compiler catches typos and invalid combinations at build time.
Wrong (agents do this):
function createLight<C extends string>(colors: C[], defaultColor?: C) { ... }
createLight(['red', 'green', 'blue'], 'purple'); // No error - purple widens C
Correct:
function createLight<C extends string>(colors: C[], defaultColor?: NoInfer<C>) { ... }
createLight(['red', 'green', 'blue'], 'purple'); // Error - 'purple' not in C
Why: NoInfer<T> (TypeScript 5.4+) prevents a parameter from influencing type inference,
ensuring stricter checks.
Wrong (agents do this):
function getUser(id: string): User { ... }
function getOrder(id: string): Order { ... }
const userId = getUserId();
getOrder(userId); // No error - but wrong!
Correct:
type UserId = string & { readonly __brand: 'UserId' };
type OrderId = string & { readonly __brand: 'OrderId' };
function getUser(id: UserId): User { ... }
function getOrder(id: OrderId): Order { ... }
const userId = getUserId();
getOrder(userId); // Error - UserId is not OrderId
Why: Branded types prevent accidentally passing one ID type where another is expected. The brand exists only at compile time - zero runtime cost.
Wrong (agents do this):
function handleStatus(status: "active" | "inactive" | "pending") {
switch (status) {
case "active":
return "Active";
case "inactive":
return "Inactive";
// 'pending' silently falls through
}
}
Correct:
function handleStatus(status: "active" | "inactive" | "pending") {
switch (status) {
case "active":
return "Active";
case "inactive":
return "Inactive";
case "pending":
return "Pending";
default: {
const _exhaustive: never = status;
throw new Error(`Unhandled status: ${_exhaustive}`);
}
}
}
Why: The never check ensures every union member is handled. When a new status is added, the
compiler flags the missing case.
Wrong (agents do this):
function processItem(item: unknown) {
const user = item as User;
console.log(user.name);
}
Correct:
function isUser(item: unknown): item is User {
return typeof item === "object" && item !== null && "name" in item && "email" in item;
}
function processItem(item: unknown) {
if (isUser(item)) {
console.log(item.name); // Safe - narrowed to User
}
}
Why: Type predicates (item is User) narrow types safely with runtime checks. Type assertions
(as User) bypass the compiler and can hide bugs.
Wrong (agents do this):
import { User, UserService } from "./user";
// User is only used as a type, but gets included in the bundle
Correct:
import type { User } from "./user";
import { UserService } from "./user";
Why: import type is erased at compile time, reducing bundle size. It also makes the intent
clear - this import is for types only.
Wrong (agents do this):
interface Config {
[key: string]: string;
}
Correct:
type Config = Record<string, string>;
// Or better - use a specific union for keys:
type Config = Record<"host" | "port" | "env", string>;
Why: Record<K, V> is more readable and composable than index signatures. When possible, use a
union for keys to get exhaustive checking.
Wrong (agents do this):
const file = openFile("data.txt");
try {
processFile(file);
} finally {
file.close();
}
Correct:
using file = openFile("data.txt");
processFile(file);
// file.close() called automatically via Symbol.dispose
Why: The using keyword (TypeScript 5.2+) provides deterministic resource cleanup via the
Disposable protocol, similar to Python's with or C#'s using.
strict: true and noUncheckedIndexedAccess: true in every projectsatisfies for type validation without wideningtype or kind field for state modelingas const for configuration objects and route mapsimport type for all type-only importsswitch with never default for union handlingany - use unknown and narrow with type guardsas for type assertions unless you genuinely know more than the compiler// @ts-ignore or // @ts-expect-error without a comment explaining whyenum - use as const objects or union types insteadFunction type - use specific function signaturesnpx claudepluginhub ofershap/typescript-best-practicesCreates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.