ts-best-practices

as bypasses the compiler. Every as is a potential runtime crash the compiler can't catch.

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "ts-best-practices" with this command: npx skills add poteto/noodle/poteto-noodle-ts-best-practices

Type Safety

Never as cast

as bypasses the compiler. Every as is a potential runtime crash the compiler can't catch.

// BAD const user = data as User;

// GOOD — validate 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 }

The one exception: a cast immediately following exhaustive validation (as above) is acceptable because the cast is earned. But prefer a type guard or schema library (Zod, Valibot) over manual validation.

Refactoring as out of existing code: When encountering an as cast, determine why TypeScript can't infer the type. Usually one of:

  • Missing discriminant field → add one, use discriminated union

  • Overly wide type (e.g. Record<string, any> ) → narrow the type definition

  • Untyped API boundary → add a type guard or schema parse at the boundary

  • Genuinely impossible to express → use a branded type or satisfies instead

unknown over any

any disables type checking for everything it touches. unknown forces you to narrow before use.

// BAD function handle(input: any) { return input.foo.bar; }

// GOOD function handle(input: unknown) { if (typeof input === "object" && input !== null && "foo" in input) { // narrowed — compiler verifies access } }

When receiving data from external sources (API responses, JSON parse, event payloads, message passing), always type as unknown and narrow.

Discriminated Unions

Model variants with a shared literal discriminant. This makes switch and if narrow automatically.

// BAD — optional fields create ambiguous states type Shape = { kind?: string; radius?: number; width?: number; height?: number };

// GOOD — impossible states are unrepresentable type Shape = | { kind: "circle"; radius: number } | { kind: "rect"; width: number; height: number };

Rules:

  • Discriminant field must be a literal type (string literal, number literal, true /false )

  • Every variant shares the same discriminant field name

  • Each variant's discriminant value is unique

Type Narrowing

Prefer compiler-understood narrowing over manual assertions.

// Narrowing patterns (best → worst): // 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 above aren't sufficient // 5. as cast — last resort, only after validation

// in operator narrowing 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

Write type guards when the compiler can't narrow automatically. Return x is T .

function isCircle(s: Shape): s is Shape & { kind: "circle" } { return s.kind === "circle"; }

Rules:

  • The guard body must actually verify the claim — a lying guard is worse than as

  • Prefer discriminated union narrowing over custom guards when possible

  • Name guards isX or hasX for readability

Exhaustiveness Checks

Use never to ensure all variants are handled. The compiler errors if a new variant is added but not handled.

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; throw new Error(unhandled shape: ${(_exhaustive as { kind: string }).kind}); } } }

Always add the default: never arm to switches over discriminated unions. When a new variant is added to the union, every switch without a case for it will fail to compile — this is the goal.

For simpler cases, a helper function can reduce boilerplate:

function absurd(x: never, msg?: string): never { throw new Error(msg ?? unexpected value: ${JSON.stringify(x)}); }

// usage in default arm: default: return absurd(s, unhandled shape);

satisfies Over as

When you need to verify a value matches a type without widening or narrowing:

// BAD — widens, loses literal types const config = { theme: "dark", cols: 3 } as Config;

// GOOD — validates AND preserves literal types const config = { theme: "dark", cols: 3 } satisfies Config; // config.theme is "dark" (literal), not string

Making Impossible States Unrepresentable

Design types so invalid states cannot be constructed:

// BAD — can be { loading: true, data: User, error: Error } simultaneously type State = { loading: boolean; data?: User; error?: Error };

// GOOD — exactly one state at a time type State = | { status: "idle" } | { status: "loading" } | { status: "success"; data: User } | { status: "error"; error: Error };

If a bug requires checking "wait, can this combination actually happen?" — the type is too loose. Tighten it so the type system answers that question at compile time.

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

adversarial-review

No summary provided by upstream source.

Repository SourceNeeds Review
-280
poteto
General

unslop

No summary provided by upstream source.

Repository SourceNeeds Review
General

brain

No summary provided by upstream source.

Repository SourceNeeds Review