| name | loom-typescript |
| description | TypeScript language expertise for type-safe, production-quality code. Use for advanced type system features (generics, discriminated unions, conditional and mapped types), strict mode configuration, type-safe APIs with zod/trpc/prisma, and modern tooling across Node, Deno, and Bun. |
| triggers | ["typescript","ts","tsx","type","interface","generic","union","intersection","discriminated union","type guard","type assertion","utility types","conditional types","mapped types","satisfies","zod","trpc","prisma","react","node","nodejs","deno","bun","npm","pnpm","yarn","type-safe","type safety","tsconfig","strict mode","branded types"] |
TypeScript Language Expertise
Overview
Type-safe, production-quality TypeScript: the type system's sharp edges, strict-mode config that actually moves the needle, the type-vs-runtime boundary, and framework patterns (zod/tRPC/Prisma/React/Express). Assumes fluency with JS and basic TS — this is reference for the traps and idioms that bite experienced engineers.
Type System Essentials
Generics
interface HasId { id: string }
class Repo<T extends HasId = HasId> {
private items = new Map<string, T>();
save(item: T): this { this.items.set(item.id, item); return this; }
find(id: string): T | undefined { return this.items.get(id); }
}
function pair<A, B>(a: A, b: B) { return [a, b] as const; }
Utility types
| Utility | Result |
|---|
Partial<T> / Required<T> | all props optional / required |
Readonly<T> | all props readonly (shallow) |
Pick<T,K> / Omit<T,K> | keep / drop keys K |
Record<K,V> | object with keys K, values V |
Extract<U,V> / Exclude<U,V> | keep / drop union members of U assignable to V |
NonNullable<T> | strip null / undefined |
ReturnType<F> / Parameters<F> | function return type / param tuple |
Awaited<T> | recursively unwrap Promise (prefer over a hand-rolled Unwrap) |
function createUser(name: string, email: string): User { }
type NewUser = ReturnType<typeof createUser>;
type NewUserArgs = Parameters<typeof createUser>;
Conditional types & infer
type Elem<T> = T extends (infer U)[] ? U : never;
type Ret<T> = T extends (...a: any[]) => infer R ? R : never;
type ToArray<T> = T extends any ? T[] : never;
type A = ToArray<string | number>;
type ToArray1<T> = [T] extends [any] ? T[] : never;
type B = ToArray1<string | number>;
Mapped types
type Mutable<T> = { -readonly [K in keyof T]: T[K] };
type Optional<T> = { [K in keyof T]+?: T[K] };
type Getters<T> = { [K in keyof T as `get${Capitalize<string & K>}`]: () => T[K] };
type PickByValue<T, V> = { [K in keyof T as T[K] extends V ? K : never]: T[K] };
Discriminated unions & exhaustiveness
Model each valid state as a variant with a shared literal discriminant; switch on it narrows each arm, and an assertNever default turns "added a variant, forgot a case" into a compile error.
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User[] }
| { status: "error"; error: Error };
function assertNever(x: never): never { throw new Error(`Unreachable: ${JSON.stringify(x)}`); }
function render(s: State): string {
switch (s.status) {
case "idle": return "Click to load";
case "loading": return "Loading…";
case "success": return `Loaded ${s.data.length}`;
case "error": return s.error.message;
default: (s);
}
}
⚠ Exhaustiveness relies on a finite discriminant. A numeric enum discriminant accepts any number, so assertNever won't catch a missing case — use string-literal unions or as const objects (see Anti-Patterns).
Type guards, assertion functions, unknown vs any
any disables checking transitively — it silently poisons every expression it flows into. unknown is the safe top type: assignable from anything, assignable to nothing until you narrow.
- Type external inputs (
JSON.parse, fetch().json(), catch vars, process.env shapes) as unknown and narrow with typeof / instanceof / in / a validator before use.
function isCat(a: Cat | Dog): a is Cat { return "meow" in a; }
function assertNonNull<T>(v: T | null | undefined, msg?: string): asserts v is T {
if (v == null) throw new Error(msg ?? "value is null/undefined");
}
⚠ An explicit x is T predicate is trusted, not verified by the compiler — a wrong body is as unsafe as as. Prefer letting TS infer the predicate (TS 5.5+, see Gotchas); reserve explicit is for what inference can't express, and unit-test those.
tsconfig & Strict Mode
module/moduleResolution must match where the output runs — there is no universal template. Pick ONE of the two below; never copy a nodenext config into a bundler app or vice versa.
Node app or published library — module: nodenext
Requires explicit .js extensions on relative imports plus "type": "module" in package.json (or .mts/.cts). nodenext implies a matching lib/target, so an explicit "lib" is redundant here.
{
"compilerOptions": {
"module": "nodenext",
"verbatimModuleSyntax": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch":
Bundler app (Vite, esbuild, webpack)
Extensionless relative imports work; the bundler emits, so tsc only type-checks (noEmit). ⚠ Never use moduleResolution: bundler for a published library — it's "infectious": emitted .d.ts files carry extensionless relative imports that break Node.js ESM consumers.
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler",
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck":
Flags beyond strict
strict: true does NOT include these — opt in explicitly (TS 5.9 tsc --init now enables them for new projects):
noUncheckedIndexedAccess — adds | undefined to array subscripts and index-signature access (arr[i]: T | undefined). ⚠ NOT applied to named properties, NOT to for...of loop variables (by design, microsoft/TypeScript#42622), NOT to Object.values() — so it's no safety net when iterating.
exactOptionalPropertyTypes — obj.x = undefined becomes an error for x?: "a" | "b"; only deleting the key makes the property absent. Matters for "x" in obj checks and serialization round-trips.
useUnknownInCatchVariables (on via strict since 4.4) — catch vars are unknown; guard with instanceof Error.
verbatimModuleSyntax — modern module-safety baseline; supersedes the now-no-op importsNotUsedAsValues/preserveValueImports (see Modules).
isolatedModules — forbids constructs single-file transpilers can't handle (re-exporting a type without type, const enum inlining); required for esbuild/swc/Babel pipelines.
Checklist: tsconfig
Modules & Declaration Merging
Type-only imports
Under verbatimModuleSyntax, an import/export WITHOUT a type modifier is emitted verbatim; anything WITH type is erased. A purely-type import missing type ships as a runtime import/require — defeating tree-shaking and dragging CJS/ESM side effects in.
import type { User, Order } from "./models";
import { type UserDTO, createUser } from "./user";
export { Order, type OrderDTO } from "./order";
Declaration merging
Interfaces (unlike type aliases) merge across declarations. Deliberate uses: augmenting third-party/global types; pairing an interface with a namespace or class of the same name.
declare global {
namespace Express { interface Request { user?: User; requestId: string } }
}
declare module "untyped-pkg" { export function doThing(v: string): void }
export {};
⚠ The flip side is a footgun: two same-named interfaces in one scope merge silently — often an accidental collision. Use a type alias for shapes you don't want merged (a duplicate type is a hard error, which is what you want).
Runtime Validation Boundary
Types are erased at runtime — they cannot validate data crossing a trust boundary (HTTP body, JSON, env, DB rows). Parse with a schema validator at the edge; inside the boundary, trust the types. Never bridge the boundary with as.
Zod
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["admin", "user", "guest"]).default("user"),
createdAt: z.coerce.date(),
});
type User = z.infer<typeof UserSchema>;
type UserIn = z.input<typeof UserSchema>;
UserSchema.parse(input);
UserSchema.safeParse(input);
UserSchema.partial();
UserSchema.pick({ email: true });
.({ : });
.({ : z.().().() });
z.({ : z.().(), : z.() })
.( d. === d., { : , : [] });
⚠ z.infer is the output type. Whenever a schema uses .default(), .transform(), or .coerce, input ≠ output — annotate parse inputs with z.input and results with z.infer. Passing an z.infer value where z.input is expected is a common, silent shape bug.
tRPC
const t = initTRPC.context<Context>().create();
const appRouter = t.router({
getUser: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(({ input, ctx }) => ctx.db.user.find(input.id)),
});
export type AppRouter = typeof appRouter;
import type { AppRouter } from "./server";
const client = createTRPCClient<AppRouter>({ url });
await client.getUser.query({ id: "…" });
The client imports the type only (import type), so no server code ships to the browser; the .input() schema doubles as runtime guard and static contract.
Prisma
const user = await prisma.user.findUnique({
where: { id },
include: { posts: { where: { published: true }, take: 10 } },
});
type UserWithPosts = Prisma.UserGetPayload<{ include: { posts: true } }>;
await prisma.$transaction(async (tx) => { });
select/include reshape the result type, not just the query — select prunes fields from the returned type. Use Prisma.<Model>GetPayload<…> to derive a shape rather than duplicating it by hand.
Patterns
Branded (nominal) types
TS is structural, so UserId and OrderId (both string) are interchangeable unless you brand them. Validate in the constructor; the brand makes mix-ups a compile error.
declare const brand: unique symbol;
type Brand<T, B> = T & { readonly [brand]: B };
type UserId = Brand<string, "UserId">;
type Email = Brand<string, "Email">;
function toEmail(s: string): Email {
if (!s.includes("@")) throw new Error("invalid email");
return s.toLowerCase() as Email;
}
Result type & async error handling
type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
async function fetchUser(id: string): Promise<Result<User>> {
try {
const res = await fetch(`/api/users/${id}`);
if (!res.ok) return { ok: false, error: new Error(`HTTP ${res.status}`) };
return { ok: true, value: (await res.json()) as User };
} catch (error) {
return { ok: false, error: error instanceof Error ? error : new Error((error)) };
}
}
retry<T>(: <T>, : , : ): <T> {
: = ();
( i = ; i < attempts; i++) {
{ (); }
(e) {
lastError = e ? e : ((e));
(i < attempts - ) ( (r, delayMs * ** i));
}
}
lastError;
}
React + TypeScript
import { type ReactNode, type ComponentPropsWithoutRef, createContext, useContext, useState, useRef } from "react";
interface InputProps extends ComponentPropsWithoutRef<"input"> {
label: string;
error?: string;
}
function List<T>({ items, render, keyOf }: {
items: T[]; render: (t: T) => ReactNode; keyOf: (t: T) => string | number;
}) {
return <ul>{items.map((it) => <li key={keyOf(it)}>{render(it)}</li>)}</ul>;
}
function useToggle(init = false) {
const [on, setOn] = useState(init);
return [on, () => ( !v)] ;
}
ref = useRef<>();
= createContext< | >();
(): {
c = ();
(!c) ();
c;
}
- ⚠ Avoid
React.FC: it doesn't support generic components and its children semantics shifted in React 18 types. Annotate the props object directly; add children: ReactNode only when the component renders children.
- Polymorphic
as prop — the one genuinely tricky component type:
type Poly<C extends React.ElementType, P = {}> =
P & { as?: C } & Omit<React.ComponentPropsWithoutRef<C>, keyof P | "as">;
function Text<C extends React.ElementType = "span">({ as, ...rest }: Poly<C, { size?: "sm" | "lg" }>) {
const Tag = as ?? "span";
return <Tag {...rest} />;
}
Node.js + Express
import type { Request, Response, NextFunction } from "express";
import { z } from "zod";
declare global {
namespace Express { interface Request { user?: User } }
}
const validate = <T>(schema: z.ZodSchema<T>) =>
(req: Request, res: Response, next: NextFunction) => {
const r = schema.safeParse(req.body);
if (!r.success) return res.status(400).json({ errors: r.error.issues });
req.body = r.data;
next();
};
app.post("/users", validate(), createUserHandler);
=
| { : ; : z. }
| { : ; : }
| { : ; : };
Common Anti-Patterns
function bad(d: any) { return d.a.b.c; }
function good(d: unknown): string { if (isValid(d)) return d.a.b.c; throw new Error("invalid"); }
const u1 = JSON.parse(input) as User;
const u2 = UserSchema.parse(JSON.parse(input));
const a = users.find((u) => u.id === id)!;
const b = users.find((u) => u.id === id) ?? throwMissing(id);
(): { { ...a, ...b }; }
mergeGood<T , U >(: T, : U): T & U { { ...a, ...b }; }
{ , }
= { : , : } ;
= ( )[keyof ];
items.( (i) => { (i); });
.(items.( (i)));
= { : ; ?: []; ?: };
= { : } | { : ; : [] } | { : ; : };
Expert Practices: Idioms, Anti-Patterns & Gotchas
Idioms
satisfies — validate without widening
satisfies (TS 4.9+) checks that an expression is assignable to a target type without replacing the expression's inferred type with that target, so downstream code keeps the narrowest per-property/literal types while still catching wrong shapes and typo'd keys. It resolves the dilemma between a : Type annotation (validates but WIDENS, losing literal/tuple precision) and an as Type assertion (preserves nothing and SUPPRESSES mismatches, so a misspelled key slips through). Mechanism: TypeScript verifies assignability to the target but records the original expression type for inference. Constraint: it applies only at an expression/initializer site — it is not a statement you can retroactively apply to an already-declared variable.
type Colors = "red" | "green" | "blue";
type RGB = [number, number, number];
const palette = {
red: [255, 0, 0],
green: "#00ff00",
} satisfies Record<Colors, string | RGB>;
palette.green.toUpperCase();
palette.red.at(0);
NoInfer<T> — mark a parameter validate-only
NoInfer<T> (TS 5.4+) tells TypeScript not to use a parameter as an inference candidate for a type variable, while still validating it against the T inferred from the principal parameters. Without it, every T-typed parameter contributes inference candidates, so a default value or callback can silently expand what T resolves to and accept out-of-range values. Use it when one parameter is the authoritative source of truth.
function createStreetLight<C extends string>(
colors: C[],
defaultColor?: NoInfer<C>,
) {}
createStreetLight(["red", "yellow", "green"], "blue");
const type parameters — keep the readonly constraint
The const modifier on a type parameter (TS 5.0+) makes inline literal arguments infer const-like (literal/tuple) types, so callers no longer need as const. The silent trap: if the constraint is mutable (T extends string[]), the const-inferred candidate readonly ['a','b'] is not assignable to it, so inference falls back to the widened mutable type with NO warning. Always use a readonly constraint. The modifier also affects only literals written directly at the call site — passing a pre-declared variable (already inferred as string[]) sees no benefit.
declare function tags<const T extends readonly string[]>(args: T): T;
const t = tags(["a", "b"]);
verbatimModuleSyntax and precise type-only imports
verbatimModuleSyntax (TS 5.0+) replaces the deprecated, now-no-op importsNotUsedAsValues/preserveValueImports with one rule: imports/exports WITHOUT a type modifier are emitted verbatim; anything WITH type is erased. So every purely-type import must be import type { ... } or use an inline type specifier — otherwise it is emitted as a runtime import even when unused, defeating tree-shaking, forcing unwanted CJS/ESM require() inclusion, and breaking cross-compiler consistency (esbuild/swc/Babel all strip type-marked imports reliably). It is in TS 5.9's tsc --init defaults. (See Modules above.)
using / await using — deterministic cleanup (TS 5.2)
Explicit Resource Management: any object implementing Symbol.dispose can be declared with using, and TypeScript guarantees dispose runs on scope exit — including early returns and exceptions — in last-in-first-out order. await using calls and awaits Symbol.asyncDispose. This replaces error-prone try/finally cleanup for DB connections, file handles, timers, and test fixtures. Requires lib to include esnext.disposable; some runtimes need a Symbol.dispose polyfill.
class DbConnection implements Disposable {
constructor(private conn: Connection) {}
[Symbol.dispose]() {
this.conn.close();
}
}
async function processRecords() {
using db = new DbConnection(openConnection());
return await db.conn.query("SELECT * FROM records");
}
Anti-Patterns
Explicit x is T predicates are trusted unconditionally — as unsafe as as
When you annotate a guard's return type as x is T, TypeScript does NOT verify the body actually narrows x to T — it trusts the assertion, making an explicit predicate semantically equivalent to a type assertion. A wrong or incomplete predicate compiles silently and causes runtime type confusion. Prefer letting TypeScript INFER the predicate from a simple narrowing body (TS 5.5+), because then the compiler derives it from the implementation. Reserve explicit is for cases inference cannot handle (multiple return paths, deep structural validation) — write them thoroughly and unit-test them.
const isString = (x: unknown) => typeof x === "string";
function isPositive(n: number): n is 1 | 2 | 3 {
return n > 0;
}
Never publish const enum in a .d.ts
A published const enum is inlined into consumers' bundles at compile time. If a later patch changes member values, consumers keep the OLD inlined values while running the NEW library — a silent wrong-branch bug. It is also incompatible with isolatedModules and single-file transpilers (Babel, esbuild, swc), which cannot inline cross-file values. For published APIs use a regular enum, an as const object, or preserveConstEnums to strip the const from declaration output.
const Direction = { Up: "UP", Down: "DOWN" } as const;
type Direction = (typeof Direction)[keyof typeof Direction];
Gotchas
TS 5.5 inferred type predicates — but truthiness and .filter(Boolean) do NOT narrow
TS 5.5 infers a type predicate for a function with no explicit return annotation, a single return statement, no parameter mutation, and a boolean expression tied to a refinement of the parameter — so arr.filter(x => x !== undefined) finally returns T[]. The trap: truthiness checks (x => !!x, x => x) and .filter(Boolean) do NOT infer a predicate. Reason — the "if and only if" rule: !!score being false could mean undefined OR the valid value 0, so score is number would be unsound; Boolean is also not itself recognized as a predicate. The result is doubly bad: the type stays (T | undefined)[] AND zero/empty-string values are silently dropped at runtime. Use explicit comparisons or a named guard.
const birds = countries.map((c) => birdMap.get(c)).filter((b) => b !== undefined);
function isDefined<T>(x: T | null | undefined): x is NonNullable<T> {
return x != null;
}
const defined = countries.map((c) => birdMap.get(c)).filter(isDefined);
useUnknownInCatchVariables types catch as unknown (on via strict since 4.4)
Catch-clause variables are unknown, not any, so touching .message/.stack without a guard fails to compile — and the break rides in silently via strict on upgrade. Use an instanceof Error guard; error as Error restores the old unsafe behavior and is only a temporary migration crutch (see the fetchUser/retry examples above).
try {
await riskyOperation();
} catch (err) {
if (err instanceof Error) console.error(err.message);
else console.error("Unknown error:", String(err));
}
Excess-property checking only fires on FRESH object literals
The "may only specify known properties" error fires only when a literal is assigned DIRECTLY to a typed target or passed DIRECTLY as an argument. Assigning the same literal to an intermediate variable first — even one with an explicit type annotation — strips its freshness, and structural typing then allows the extra properties silently. Refactoring a direct literal into a named variable "for readability" can suppress a real bug the compiler was catching.
interface Duck {
quack(): void;
}
const d: Duck = { quack() {}, woof() {} };
const obj = { quack() {}, woof() {} };
const d2: Duck = obj;
Control-flow narrowing is discarded inside closures — copy to a const
TypeScript drops a variable's narrowing when it is captured by a closure, even if unconditionally assigned beforehand, because the captured binding could be reassigned between narrowing and execution (acknowledged design limitation, microsoft/TypeScript#37802). Copy the narrowed value into a fresh const so the closure captures an immutable binding.
function deferred(value?: string): () => string {
if (value == null) value = "";
const v = value;
return () => v;
}
Method-shorthand syntax is checked bivariantly — strictFunctionTypes does not catch it
strictFunctionTypes enforces contravariant parameter checking for function-TYPED properties (m: (x: T) => void), but the docs explicitly exempt parameters of methods declared in shorthand syntax (m(x: T): void) — these stay BIVARIANT. The exemption lets Array<T> relate covariantly, but in user-defined interfaces it is a real soundness hole. The typescript-eslint rule method-signature-style can force property syntax to close the gap.
interface Processor {
process: (value: string | number) => void;
}
const p: Processor = {
process: (value: string) => console.log(value),
};
Template-literal types produce Cartesian products
A template-literal type interpolating multiple unions expands to the full Cartesian product: unions of size N and M yield N*M members, growing multiplicatively and becoming a real compile-time cost for large schemas. The bounded event-name pattern is the canonical good use; for large route maps or i18n keys prefer code generation (tsc --generateTrace surfaces the cost). Note the intrinsic Uppercase/Lowercase/Capitalize types use raw JS toUpperCase/toLowerCase — they are NOT locale-aware.
type PropEventSource<T> = {
on<K extends string & keyof T>(event: `${K}Changed`, cb: (v: T[K]) => void): void;
};
Performance
Prefer interface extends over type intersection for object types
interface Foo extends Bar, Baz produces a single flat object type whose relationships the compiler caches, whereas type Foo = Bar & Baz forces a recursive merge of constituents on every comparison at each use site. Effects: faster type-checking / better language-server responsiveness in large codebases; conflicting properties are reported eagerly at the declaration instead of silently collapsing to never at use sites; cleaner IDE hover. The TS Performance wiki names this a high-impact optimization. Intersections remain necessary for composing non-object types (unions, primitives, mapped/conditional results).
interface AdminUser extends BaseUser, AdminPermissions {
adminLevel: number;
}
isolatedDeclarations unlocks parallel .d.ts emit
isolatedDeclarations (TS 5.5+) requires explicit type annotations on all exported symbols so each file's .d.ts can be generated independently, without a whole-program type-checker pass — letting tools (Oxc, esbuild, swc) emit declarations in parallel and removing the monorepo serialization bottleneck. Requires declaration or composite. Tradeoff: explicit return types on exported functions become mandatory; it pays off most when you already enforce explicit-return-types via ESLint.
export function computeTotal(items: Item[]): number {
return items.reduce((sum, i) => sum + i.price, 0);
}
Currency
Import attributes: with { type: 'json' }, not assert
Import assertions using the withdrawn assert keyword were superseded by import attributes using with (ES2025). Under --module nodenext, TS 5.8 makes assert a hard error (matching Node.js 22+), and TS 5.7 already required with for validated JSON imports under nodenext. Migrate all assert { type: 'json' } to with { type: 'json' }.
import config from "./config.json" with { type: "json" };
TS 6.0 deprecates / TS 7.0 (Go compiler) removes es5 target, baseUrl, node10 resolution
TS 6.0 is the LAST JavaScript-based release; it DEPRECATES, and the Go-rewritten TS 7.0 REMOVES: --target es3/es5 (ES2015 becomes the minimum), --baseUrl (migrate path aliases to the Node-native package.json#imports map, supported by both Node and TS without a build step), and moduleResolution: node10/classic. Down-leveling for ancient targets belongs in Babel/esbuild, not tsc. The deprecations land in 6.0; the removals in 7.0 — do not conflate the two.
{ "imports": { "#utils/*": "./src/utils/*.js", "#models/*": "./src/models/*.js" } }
Checklist: type-safety review before done