CtrlK
BlogDocsLog inGet started
Tessl Logo

typescript-best-practices

Type-level discipline for TypeScript: discriminated unions, branded types, constructive modeling, narrowing hierarchy, exhaustiveness checks, boundary validation. Use when reading or editing any .ts or .tsx file, modelling domain types, or refactoring an `as` cast out of existing code.

68

Quality

83%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

SKILL.md
Quality
Evals
Security

TypeScript Best Practices

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

RuleSummary
Discriminated unionsModel variants with a kind literal discriminant so impossible states cannot be represented. No optional-field bags.
Branded typesBrand primitives with & { readonly __brand: 'X' } so they cannot be mixed up. Validate once at creation.
Constructive modelingBuild 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 typeKeep 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 anyExternal data is unknown. any disables type checking everywhere it touches.
No as castsEvery as is a runtime crash waiting. Cast only after validation.
Narrowing hierarchyDiscriminant switch > in operator > typeof/instanceof > user-defined type guard > as.
Type guardsMust 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.
ExhaustivenessInline const _exhaustive: never = x in default arms so the compiler errors when a variant is added.
satisfies over asValidates the value without widening literal types.
Boundary validationParse 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 typesReach for Pick/Omit/Parameters/ReturnType/Awaited/typeof before declaring a new interface.
Object argsPass objects, not positional args, so argument order is self-documenting. Skip on hot paths: per-frame render, tokenizers, parsers.
Real testsDo 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 telemetryStructured 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.

// 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.

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.

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.

// 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.

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, MIT, copyright 2026 Lauren Tan.

Repository
monkilabs/opencastle
Last updated
First committed

Is this your skill?

If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.