
Typescript Best Practices
- 531 installs
- 2.5k repo stars
- Updated August 5, 2026
- cursor/plugins
Apply TypeScript conventions—strict typing, module boundaries, error handling, and testable patterns—while implementing Cursor plugin extension code.
About
TypeScript-best-practices skill encodes Cursor plugin coding standards: strict types, disciplined modules, safe async patterns, and maintainable structures for extension UI, commands, and shared library code.
- Strict typing defaults
- Clear module boundaries
- Predictable error handling
- Testable pure helpers
- Consistent public types
Typescript Best Practices by the numbers
- 531 all-time installs (skills.sh)
- Ranked #607 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cursor/plugins --skill typescript-best-practicesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 531 |
|---|---|
| repo stars | ★ 2.5k |
| Last updated | August 5, 2026 |
| Repository | cursor/plugins ↗ |
What it does
Apply TypeScript conventions—strict typing, module boundaries, error handling, and testable patterns—while implementing Cursor plugin extension code.
Files
TypeScript best practices
Apply the type-system-discipline principle skill first; this skill grounds it in TypeScript syntax.
| Rule | Summary |
|---|---|
| Discriminated unions | Model variants with a kind literal discriminant so impossible states can't be represented. No optional-field bags. |
| Branded types | Brand primitives with & { readonly __brand: "X" } so they can't be mixed up. Validate once at creation. |
unknown over any | External data is unknown. any disables type checking everywhere it touches. |
No as casts | Every as is a runtime crash waiting. Cast only after validation. |
| Narrowing hierarchy | Discriminant switch > in operator > typeof/instanceof > user-defined type guard > as. |
| Type guards | Must verify the claim. A lying guard is worse than as because the bug hides behind a name that says it's safe. Name them isX or hasX. |
| Exhaustiveness | Inline const _exhaustive: never = x; in default arms so the compiler errors when a new variant is added. |
satisfies over as | Validates the value without widening literal types. |
| Boundary validation | Validate where data crosses in; trust types inside. See the boundary-discipline principle skill. |
| Schema-derived types | Reach for Pick/Omit/Parameters/ReturnType/Awaited/typeof before declaring a new interface. |
| Object args | Pass objects, not positional, so argument order is self-documenting. Skip on hot paths (per-frame render, tokenizers, parsers). |
Examples: references/patterns.md.
TypeScript patterns
Code examples for each rule in SKILL.md. The underlying principles are language-agnostic; see the type-system-discipline and boundary-discipline principle skills.
Branded types
Brand primitives so they can't be mixed up. Validate once at creation; downstream code trusts the type.
type AgentId = string & { readonly __brand: "AgentId" };
function parseAgentId(input: string): AgentId {
if (!isUUID(input)) throw new Error(`Invalid agent id: ${input}`);
return input as AgentId;
}
function focusAgent(id: AgentId): void {
/* input is trusted */
}Match the readonly __brand: 'X' shape; don't invent a new convention.
Discriminated unions
If a bug forces the question "wait, can this combination actually happen?", the type is too loose. Model variants with a literal discriminant: every variant shares the field name and each variant's value is unique, so impossible combos can't be represented.
// Don't. Boolean + optionals lets contradictory states exist.
type DiffState = { loading: boolean; diff?: GitDiff; error?: string };
// Do. Only valid states exist.
type DiffState =
| { kind: "loading" }
| { kind: "ready"; diff: GitDiff }
| { kind: "error"; error: string };Pick one discriminant name (kind, type, tag) and stick to it.
unknown over any
any disables type checking for everything it touches. External data is always unknown. Narrow before use.
// Don't
function handle(input: any) {
return input.foo.bar;
}
// Do
function handle(input: unknown) {
if (typeof input === "object" && input !== null && "foo" in input) {
// narrowed; compiler verifies access
}
}External sources include RPC payloads, JSON.parse, postMessage, IPC, file contents, environment variables, database results.
No as casts
Every as is a potential runtime crash. Cast only after the type system has verified the claim.
// Don't
const user = data as User;
// Do. Earn the cast at the boundary.
function parseUser(data: unknown): User {
if (typeof data !== "object" || data === null) {
throw new Error("expected object");
}
if (!("id" in data) || typeof (data as Record<string, unknown>).id !== "string") {
throw new Error("expected id");
}
// ... validate all fields
return data as User; // OK, earned cast after full validation
}When refactoring an as out of existing code, identify why TypeScript can't infer:
- Missing discriminant: add one, switch to a discriminated union.
- Overly wide source type (e.g.
Record<string, unknown>): narrow it. - Untyped boundary: add a parse function or schema.
- Genuinely inexpressible: use a branded type or
satisfies.
Narrowing hierarchy
From best to last-resort:
1. Discriminated union switch / if. Compiler narrows automatically. 2. `in` operator. "key" in obj narrows to variants containing that key. 3. `typeof` / `instanceof`. For primitives and class instances. 4. User-defined type guard. When the above aren't enough. 5. `as` cast. Only after validation.
function area(s: Shape): number {
if ("radius" in s) return Math.PI * s.radius ** 2; // narrowed to circle
return s.width * s.height; // narrowed to rect
}Type guards
A guard must actually verify the claim. A lying guard is worse than as because the bug hides behind a name that says it's safe.
function isCircle(s: Shape): s is Shape & { kind: "circle" } {
return s.kind === "circle";
}Prefer discriminant narrowing when possible. The guard adds a layer the reader has to follow.
Exhaustiveness
In default arms, assign the discriminant to a never-typed local. The compiler errors if a new variant is added without handling.
// Value-returning switch
function area(s: Shape): number {
switch (s.kind) {
case "circle":
return Math.PI * s.radius ** 2;
case "rect":
return s.width * s.height;
default: {
const _exhaustive: never = s;
return _exhaustive;
}
}
}
// Void switch
function handle(s: Shape): void {
switch (s.kind) {
case "circle":
drawCircle(s);
break;
case "rect":
drawRect(s);
break;
default: {
const _exhaustive: never = s;
void _exhaustive;
}
}
}Return-style in value-returning switches; void-style in statement switches.
satisfies over as
satisfies validates without widening literal types.
// Don't. Widens, loses literal types.
const config = { theme: "dark", cols: 3 } as Config;
// Do. Validates AND preserves literal types.
const config = { theme: "dark", cols: 3 } satisfies Config;
// config.theme is "dark" (literal), not stringBoundary validation
Validate once where data crosses in; trust types inside. See the boundary-discipline principle skill.
- Wire formats (proto, JSON-RPC): parse with
ignoreUnknownFieldsso forward-compatible changes don't break old clients. - Persisted JSON: versioned blob with a try/catch around the parse.
- Don't re-validate deep in call chains.
Schema-derived types
When a .proto, OpenAPI spec, GraphQL schema, or database migration already defines a shape, derive from the generated types instead of duplicating them.
// Don't. Duplicate shape, drifts when the schema changes.
type CheckSummary = {
totalCount: number;
checks: { name: string; status: string }[];
};
function renderChecks(s: CheckSummary) {
/* ... */
}
// Do. Derive from the generated schema type.
import type { ChecksMessage } from "<generated module>";
function renderChecks(s: Pick<ChecksMessage, "totalCount" | "checks">) {
/* ... */
}Reach for Pick, Omit, Parameters, ReturnType, Awaited, typeof before writing a new interface.
Object args
// Don't. Swap two args, still compiles.
openFile(uri, {
startLineNumber: 10,
startColumn: 1,
endLineNumber: 10,
endColumn: 1,
});
// Do. Order-independent, self-documenting.
openFile({
uri,
selection: {
startLineNumber: 10,
startColumn: 1,
endLineNumber: 10,
endColumn: 1,
},
});Skip on hot paths: per-frame render, tokenizers, parsers, anything in a tight loop where the allocation cost matters.