Skip to content
Back to skills

Typescript Best Practices

ASecurity

Type design in TypeScript: discriminated unions, branded types, narrowing, exhaustiveness and boundary validation. Use when modelling domain types, tightening strictness or generics, removing `as` casts, or migrating code to stricter types.

  • 80 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added September 5, 2026
ai-agentstypescriptrustexpressrefactoringgitdatabase

Works with

  • cursor

Security analysis

A100/100

Scanned October 5, 2026

npx -y skills add monkilabs/opencastle --skill typescript-best-practices --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Typescript Best Practices?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Typescript Best Practices
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/monkilabs-typescript-best-practices/badge)](https://www.skillsdirectory.com/skills/monkilabs-typescript-best-practices)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: typescript-best-practices
description: "Type design in TypeScript: discriminated unions, branded types, narrowing, exhaustiveness and boundary validation. Use when modelling domain types, tightening strictness or generics, removing `as` casts, or migrating code to stricter types."
---

# TypeScript Best Practices

Make the illegal state unrepresentable, then let the compiler enforce it.

| Rule | Summary |
|------|---------|
| Discriminated unions | Model variants with a `kind` literal discriminant so impossible states cannot be represented. No optional-field bags. |
| Branded types | Brand primitives with `& { readonly __brand: 'X' }` so they cannot be mixed up. Validate once at creation. |
| Constructive modeling | Build the shape so the illegal value cannot be constructed. `[T, ...T[]]` for non-empty, `[T, T][]` for even length, `start` plus `duration` for a range. Not a runtime guard. |
| Simplest total type | Keep `T[]` while every operation on it stays total. Strengthen to `NonEmpty<T>` only where the loose type forces `!`, a cast, or a "should never happen" throw. |
| `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 is safe. Name them `isX` or `hasX`. |
| Exhaustiveness | Inline `const _exhaustive: never = x` in default arms so the compiler errors when a variant is added. |
| `satisfies` over `as` | Validates the value without widening literal types. |
| Boundary validation | Parse where data crosses in, into a named domain type. `Record<string, unknown>` stops at that parse. Trust types inside; do not re-validate deep in call chains. |
| Schema-derived types | Reach for `Pick`/`Omit`/`Parameters`/`ReturnType`/`Awaited`/`typeof` before declaring a new interface. |
| Object args | Pass objects, not positional args, so argument order is self-documenting. Skip on hot paths: per-frame render, tokenizers, parsers. |
| Real tests | Do not mock what you can run. Prefer real test primitives and verify UI in a running build. Mock only what you cannot run locally. |
| Structured telemetry | Structured logger diagnostics with enough context to debug from an id. No `console.log` in shipped code. |

External data is anything from RPC payloads, `JSON.parse`, `postMessage`, IPC, file contents, environment variables, or database results.

## Patterns

**Discriminated union.** If a bug forces the question "can this combination actually happen?", the type is too loose.

```ts
// Don't. Boolean plus 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 }
```

**Branded type.** Validate once at creation; downstream code trusts the type.

```ts
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
}
```

**Constructive modeling.** Build from parts that are all legal instead of restricting a loose type with runtime checks. TypeScript has no refinement types, and you do not need one.

```ts
type NonEmpty<T> = [T, ...T[]]
type Pairs<T> = [T, T][]

const isNonEmpty = <T>(arr: T[]): arr is NonEmpty<T> => arr.length > 0

// Don't: a comment holds the invariant.
type TimeRange = { start: Date; end: Date } // start <= end
// Do: a negative range cannot be written. Derive end when needed.
type TimeRange = { start: Date; durationMs: number }
```

**Simplest total type.** Do not strengthen everything. `const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0)` is fine, because `[]` is 0. Strengthen when the loose type forces a lie at a use site; the tells are `!`, `arr[0] as T`, and a "should never happen" throw.

```ts
// Don't: partiality smuggled past the compiler.
const newest = (sessions: Session[]): Session => sessions.at(0)!
// Do: strengthen the input, and the assertion disappears.
const newest = (sessions: NonEmpty<Session>): Session => sessions[0]
```

Weakening the result to `Session | undefined` is the other total signature. Either way the empty case lands at the call site, the one place that knows what empty means.

**Earned cast.** When refactoring an `as` out of existing code, identify why the compiler cannot infer: a missing discriminant (add one), an overly wide source type (narrow it), an untyped boundary (add a parse function or schema), or something genuinely inexpressible (use a brand or `satisfies`).

**Exhaustiveness.** Return-style in value-returning switches, void-style in statement switches.

```ts
default: {
  const _exhaustive: never = s
  return _exhaustive   // or: void _exhaustive
}
```

**`satisfies` over `as`.** `const config = { theme: 'dark', cols: 3 } satisfies Config` validates and keeps `config.theme` as the literal `'dark'`. The `as Config` form widens it to `string`.

Adapted from the `typescript-best-practices` skill in [cursor/plugins](https://github.com/cursor/plugins/tree/main/pstack), MIT, copyright 2026 Lauren Tan.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…