mcpbeat Sign in

Typescript Agent Skill

Use when writing or fixing TypeScript type code (.ts/.tsx/.d.ts), modeling data with generics/unions/branded types, diagnosing narrowing failures, or configuring tsconfig and the build toolchain. NOT runtime code, fs/streams/http (that is `nodejs`); NOT component/route typing (that is `react`/`nextjs`); NOT ORM type-gen (that is `drizzle-orm`).

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill typescript

What comes with it

17 331 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/build-and-monorepo.md
references/type-system.md
scripts/verify.sh

The instruction itself

16 sections, as written by the author

Idiomatic TypeScript types

Express data and contracts precisely in the TypeScript type system, and configure the

compiler so the types actually hold.

Targets TypeScript 5.9 (current stable, Q1 2026): redesigned tsc --init defaults

(module: nodenext, target: esnext, moduleDetection: force, strict on), import defer

for deferred module evaluation, expandable editor hovers. The **TypeScript 7 native

compiler** (tsgo, the Go port) is in preview as @typescript/native-preview and

type-checks roughly 10x faster (VS Code's 1.5M LOC: 89s -> 8.74s) — adopt it for fast

local checks; stable 7.0 lands early 2026.

Boundary: language vs runtime vs framework

Three layers, three owners. Language + compiler config lives here — generics, narrowing,

.d.ts, tsconfig, and the build toolchain (tsc vs tsgo, tsx, resolution mode).

Runtime behavior (how a stream backpressures, how the event loop schedules) is nodejs.

Framework idioms (component props, route maps, server-component typing) are

react/nextjs/nestjs — those *borrow* this skill's type mechanics but own their wiring.

A tsconfig question is here; a "stream backpressure" question is nodejs; a "type my

useReducer state" question is react. Also delegate: ORM schema-to-type generation ->

drizzle-orm / prisma-orm; REST/contract shape design -> api-design; test runner and E2E

setup -> testing-web / e2e-testing; injection/authz review -> secure-coding; Python

typing -> python; Go -> go.

Decision rules

Apply these on every TypeScript edit:

  • Prefer inference; annotate boundaries. Let TS infer locals and returns; annotate

function parameters, public APIs, and .d.ts. Why: redundant annotations drift from

reality and suppress the better inferred type.

  • import type for type-only imports, with verbatimModuleSyntax: true. Why: it

prevents emitting type-only imports as runtime requires and lets esbuild/swc transpile

each file alone.

  • Narrow, don't cast. Reach for type guards, in, typeof, discriminant checks before

as; when as is unavoidable, only as <narrower> or as unknown as T at a true I/O

boundary. Why: as asserts a claim the compiler cannot verify — it is a silenced error,

and as to a *wider* or unrelated type is almost always a real bug hidden.

The type-system toolbox

Generics + constraints, and const type parameters

Constrain type parameters so callers get errors at the call site, and use a const type

parameter (TS 5.0+) to preserve literal inference without as const everywhere:

// Bad: unconstrained T, and the literal "a"|"b" widens to string.
function pick<T>(obj: T, key: string) { return (obj as any)[key]; }

// Good: K is constrained to obj's keys; const T keeps the literal union.
function pick<const T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}
const r = pick({ a: 1, b: "x" } as const, "b"); // r: "x", not string

unknown over any at every boundary

any disables checking and silently poisons every downstream type it touches; unknown

forces a narrow before use.

// Bad: any flows everywhere, no error ever fires downstream.
function parse(json: string): any { return JSON.parse(json); }

// Good: unknown forces a narrow before the value is trusted.
function parse(json: string): unknown { return JSON.parse(json); }
const data = parse(input);
if (typeof data === "object" && data && "id" in data) { /* now narrowed */ }

satisfies for config validation without widening

type Route = { path: string; method: "GET" | "POST" };

// Bad: annotation widens — routes.home.method is "GET" | "POST", and a typo in
// a key would only surface far away.
const a: Record<string, Route> = { home: { path: "/", method: "GET" } };

// Good: satisfies validates each entry AND keeps literal types.
const routes = {
  home: { path: "/", method: "GET" },
} satisfies Record<string, Route>;
routes.home.method; // "GET" (narrow), and "GE" would error right here

Discriminated unions + exhaustive never

Model state as a discriminated union, not an optional-field bag:

{ data?: T; error?: E; loading?: boolean } permits loading && error; a literal discriminant

makes illegal states unrepresentable. That discriminant must be a literal type (not

string), and you must check it before destructuring — destructuring first throws away

the narrowing.

type State =
  | { status: "loading" }
  | { status: "ok"; data: string }
  | { status: "error"; message: string };

// Bad: destructured before the check — `data`/`message` are typed as the union.
function render({ status, data, message }: State) { /* no narrowing */ }

// Good: switch on the literal discriminant, exhaustive never guard at the end.
function render(s: State): string {
  switch (s.status) {
    case "loading": return "...";
    case "ok": return s.data;
    case "error": return s.message;
    default: { const _exhaustive: never = s; return _exhaustive; }
  }
}

Add a "deleted" member to State and the default branch fails to compile — that is the

point.

Branded (nominal) types

TS is structural, so a UserId and a raw string are interchangeable unless you brand them:

type UserId = string & { readonly __brand: "UserId" };
const asUserId = (s: string): UserId => s as UserId; // brand only at the validated boundary
function load(id: UserId) { /* a bare string is now a compile error */ }

Utility types cheat row

Partial<T> Required<T> Readonly<T> Pick<T,K> Omit<T,K> Record<K,V>

Exclude<U,M> Extract<U,M> NonNullable<T> ReturnType<F> Parameters<F>

Awaited<T> NoInfer<T> (TS 5.4+, blocks a type param from inferring at one site).

Mapped / conditional / template-literal one-liners

type Mutable<T> = { -readonly [K in keyof T]: T[K] };          // strip readonly
type Nullable<T> = { [K in keyof T]: T[K] | null };            // mapped transform
type Unwrap<T> = T extends Promise<infer U> ? U : T;           // conditional + infer
type EventName<T extends string> = `on${Capitalize<T>}`;       // template literal

Deep recipes (recursive types, declaration merging, variance/in-out, assertion functions,

overloads) live in references/type-system.md.

tsconfig: strict base

Copy-paste base. Every package extends this and overrides only paths/references:

{
  "compilerOptions": {
    "strict": true,                    // the whole strict family, non-negotiable
    "noUncheckedIndexedAccess": true,  // arr[i] / rec[k] are T | undefined
    "verbatimModuleSyntax": true,      // explicit import type; safe single-file transpile
    "isolatedModules": true,           // each file transpilable alone (esbuild/swc/babel)
    "skipLibCheck": true,              // don't type-check node_modules .d.ts (speed)
    "resolveJsonModule": true,         // import data.json with types
    "target": "esnext",
    "module": "nodenext",              // or "esnext" + "moduleResolution": "bundler"
    "moduleResolution": "nodenext",
    "moduleDetection": "force",
    "noEmit": true                     // a bundler/tsc -b emits; the base just checks
  }
}

moduleResolution: bundler vs nodenext

| Aspect | bundler | nodenext |

| --- | --- | --- |

| Use when | Vite / Next / Remix / esbuild build the code | Code runs directly in Node (no bundler) |

| Relative import extensions | omit (./util) | required (./util.js) |

| package.json exports/imports | resolved | resolved |

| module setting | esnext/preserve | nodenext |

| Emits runnable JS itself | no (the bundler does) | yes |

Pick bundler when a bundler owns resolution; pick nodenext for libraries and scripts

that Node executes directly. Field-by-field reference and the full matrix are in

references/build-and-monorepo.md.

Monorepo & build

Use project references so packages type-check independently and in dependency order. A

shared tsconfig.base.json holds the strict flags; each package extends it, sets

composite: true, outDir/rootDir, and lists upstream packages under references. Build

the graph with tsc -b (or tsgo -b).

For declaration emit and publishing a library, set declaration: true,

declarationMap: true, and an exports map in package.json. For fast local checks prefer

tsgo (TS7 preview: npx @typescript/native-preview); run scripts with tsx instead of

ts-node. Depth, build-order graph, path aliases, and the tsgo adoption steps are in

references/build-and-monorepo.md.

Anti-patterns

| Anti-pattern | Why it bites | Do instead |

| --- | --- | --- |

| any as an escape hatch | disables all checking and spreads silently downstream | unknown + narrow, or a precise type / generic |

| as to silence "not assignable" | asserts a claim TS can't verify — hides the real bug | fix the type, or narrow with a guard/in/typeof |

| enum for a small fixed set | emits runtime code, awkward with isolatedModules, no literal subtyping | union of string literals ("a" \| "b") + as const |

| Destructure before the discriminant check | narrowing is lost; fields become the full union | check obj.kind first, then read fields |

| Skipping noUncheckedIndexedAccess | arr[i]/rec[k] typed as T but are undefined at runtime | enable it; handle the \| undefined |

| Function / bare object / {} types | accept almost anything, no call-signature safety | a precise signature, or Record<string, unknown> |

| No exhaustive never guard on unions | a new union member compiles with a missing branch | default: const _: never = x in every switch |

| One giant tsconfig for a monorepo | can't express per-package targets; breaks references | tsconfig.base.json + per-package extends |

| // @ts-ignore | swallows the error even after the line is fixed, masking new ones | // @ts-expect-error (errors if the line later type-checks) |

| Default-importing in a CJS-emit file under verbatimModuleSyntax | blocked / wrong emit | use export =/named, or set the right module |

Project grounding

When working inside a real workspace, record this project's tsconfig and strictness choices

(resolution mode, which strict flags are on, the build tool) in the project wiki via

../harness/SKILL.md — so the next session does not re-derive them.

How to use it

Copy the folder

Take ericrisco/typescript from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.

Install what it needs

The instructions reference npx. Without those the skill loads but fails at the first command.