skills/poteto/noodle/ts-best-practices

ts-best-practices

SKILL.md

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.

Weekly Installs
4
Repository
poteto/noodle
GitHub Stars
9
First Seen
Today
Installed on
opencode4
gemini-cli4
claude-code4
github-copilot4
codex4
kimi-cli4