원클릭으로
strict-typescript
Rust-inspired TypeScript patterns. Must use when reading or writing TypeScript/JavaScript files.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Rust-inspired TypeScript patterns. Must use when reading or writing TypeScript/JavaScript files.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Creating and maintaining CLAUDE.md project memory files that provide non-obvious codebase context. Use when (1) creating a new CLAUDE.md for a project, (2) adding architectural patterns or design decisions to existing CLAUDE.md, (3) capturing project-specific conventions that aren't obvious from code inspection.
Use Bun instead of Node.js, npm, pnpm, or vite. Provides command mappings, Bun-specific APIs, and development patterns.
Configure Bun's built-in test runner with Jest-compatible APIs. Use when setting up testing infrastructure, writing unit/integration/snapshot tests, migrating from Jest, or configuring test coverage. 3-10x faster than Jest.
Generates comprehensive documentation explaining how a codebase works, including architecture, key components, data flow, and development guidelines. Use when user wants to understand unfamiliar code, create onboarding docs, document architecture, or explain how the system works.
| name | strict-typescript |
| description | Rust-inspired TypeScript patterns. Must use when reading or writing TypeScript/JavaScript files. |
Rust-inspired TypeScript. The type system is a proof system -- if it compiles, it's correct. No escape hatches.
TypeScript's type system is structural and cooperative. It cannot enforce constraints across code you don't control (third-party libraries, runtime behavior). Strictness is only valuable when the compiler can guarantee it end-to-end.
Enforce aggressively things the compiler can actually check:
noUncheckedIndexedAccess (the compiler forces you to handle undefined)unknown + zod parsing at boundaries (the compiler forces you to parse before use)Don't enforce things the compiler cannot guarantee across boundaries:
readonly / ReadonlyArray<T> on types that will be passed to libraries expecting mutable types. The annotation claims immutability but the compiler can't enforce it once the value leaves your code. This creates false confidence and forces as casts to interoperate -- the exact escape hatch we're trying to avoid.exactOptionalPropertyTypes in tsconfig. It distinguishes "missing" from "present but undefined", but JSON.parse, most libraries, and spread syntax don't respect this distinction. Same boundary problem as readonly -- it forces workarounds at every integration point.The litmus test: if a strictness annotation forces you to use as or as unknown as somewhere else, the annotation is making the type system lie. Remove the annotation, not the safety.
When in doubt, be defensive with zod parsing at boundaries rather than adding type-level constraints that can't survive contact with external code.
These are non-negotiable. Every one is enforced by tooling (biome, tsconfig, or both).
as, ! (non-null assertion), or <Type> angle-bracket casts. The biome plugin biome-plugin-no-type-assertion enforces this. If you think you need as, you have a design problem. Use satisfies when you want to verify a value conforms to a type without widening or narrowing it.any. Never use any. Use unknown and parse it with zod.@ts-ignore or @ts-expect-error. Fix the type error. Don't suppress it.type over interface. Always use type. Never use interface. type is closed (cannot be extended by declaration merging), composes with & and |, and behaves more like Rust's struct and enum. interface is open, which we don't want.type.unknown and must be parsed through a zod schema before use. No manual type guards as the primary narrowing strategy.userString, emailStr, itemsArray, configMap, IUser, TResult are all wrong. user, email, items, config are right.rawUser/parsedUser -- that's hungarian notation for lifecycle stage and leaves the dangerous binding in scope.function validate(id: Uuid) over function validate(uuid: string). The compiler can check types; it can't check that you passed the right string. Use zod-branded types liberally for domain primitives: Uuid, EmailAddress, SlackChannelId, Url.Uuid not UUID, HttpClient not HTTPClient, ApiKey not APIKey, JsonParser not JSONParser. Matches Rust's clippy::upper_case_acronyms. All-caps acronyms are unreadable when compounded (HTTPSURLConnection vs HttpsUrlConnection).Types define the contract before implementation:
type declarations firstThe TypeScript equivalent of Rust's enum:
// Good: only valid combinations possible
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
// Bad: allows invalid combinations like { loading: true, error: Error }
type RequestState<T> = {
loading: boolean;
data?: T;
error?: Error;
};
neverThe TypeScript equivalent of Rust's exhaustive match:
type Status = "active" | "inactive" | "pending";
function processStatus(status: Status): string {
switch (status) {
case "active":
return "processing";
case "inactive":
return "skipped";
case "pending":
return "waiting";
default: {
const _exhaustive: never = status;
throw new Error(`unhandled status: ${_exhaustive}`);
}
}
}
Adding a new variant to Status causes a compile error at every switch that doesn't handle it. This is the whole point.
asUse zod to construct branded types. This avoids as casts entirely:
import { z } from "zod";
const UserId = z.string().uuid().brand<"UserId">();
type UserId = z.infer<typeof UserId>;
const OrderId = z.string().uuid().brand<"OrderId">();
type OrderId = z.infer<typeof OrderId>;
// Compiler prevents passing OrderId where UserId expected.
// Construction goes through the schema -- no `as` needed.
function getUser(id: UserId): Promise<User> {
/* ... */
}
const userId = UserId.parse("550e8400-e29b-41d4-a716-446655440000"); // UserId
const orderId = OrderId.parse("550e8400-e29b-41d4-a716-446655440001"); // OrderId
getUser(orderId); // Compile error
satisfies over as for conformance checksWhen you want to verify a value conforms to a type without widening or narrowing it, use satisfies. It validates the type at compile time while preserving the narrower inferred type:
// Bad: `as` widens -- you lose the literal types
const config = {
port: 3000,
host: "localhost",
} as Config;
// config.port is `number`, not `3000`
// Good: `satisfies` checks conformance without widening
const config = {
port: 3000,
host: "localhost",
} satisfies Config;
// config.port is `3000`, config.host is `"localhost"`
// Good: validate a record has the right shape while keeping literal keys
const routes = {
home: "/",
about: "/about",
contact: "/contact",
} satisfies Record<string, string>;
// typeof routes still knows about `home`, `about`, `contact` keys
type CreateUser = {
email: string;
name: string;
};
type UpdateUser = Partial<CreateUser>;
type User = CreateUser & {
id: UserId;
createdAt: Date;
};
Zod is our serde::Deserialize. It is the single source of truth for data shapes.
import { z } from "zod";
const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string().min(1),
createdAt: z.string().transform((s) => new Date(s)),
});
type User = z.infer<typeof UserSchema>;
parse vs safeParseparse at trust boundaries where invalid data is a bug (API client responses, config). Throws on failure. Like Rust's .unwrap() -- use it when failure means a contract violation.safeParse for user input where failure is expected (form data, CLI args). Returns a discriminated union. Like Rust's Result<T, E>.// Trust boundary: API response must conform to contract
export async function fetchUser(id: string): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`fetch user ${id} failed: ${response.status}`);
}
const data: unknown = await response.json();
return UserSchema.parse(data);
}
// User input: failure is expected, return Result-like
export function parseUserInput(
raw: unknown,
): z.SafeParseReturnType<unknown, User> {
return UserSchema.safeParse(raw);
}
const result = parseUserInput(formData);
if (!result.success) {
handleErrors(result.error.flatten().fieldErrors);
return;
}
await submitUser(result.data);
const CreateUserSchema = UserSchema.omit({ id: true, createdAt: true });
const UpdateUserSchema = CreateUserSchema.partial();
const UserWithPostsSchema = UserSchema.extend({
posts: z.array(PostSchema),
});
Parse environment at startup. Crash on invalid config. Access the typed object everywhere else.
import { z } from "zod";
const ConfigSchema = z.object({
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
API_KEY: z.string().min(1),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
export const config = ConfigSchema.parse(process.env);
Prefer const over let. Use let only when reassignment is required. Use as const for literal values that should not widen. Never mutate function parameters -- return new objects/arrays instead.
const ROLES = ["admin", "user", "guest"] as const;
type Role = (typeof ROLES)[number]; // "admin" | "user" | "guest"
function withTimeout(config: Config, timeout: number): Config {
return { ...config, timeout };
}
readonly on TypesDo not use readonly, Readonly<T>, or ReadonlyArray<T> on type fields, function parameters, or return types. These annotations are cooperative -- they only constrain code that opts in. Third-party libraries overwhelmingly use mutable types (T[], not ReadonlyArray<T>). Marking your own types readonly then passing them to library functions forces one of:
as casts to strip readonly (violates rule #1)Option 3 is the honest one. Rust's borrow checker works because it is enforced across all code, including dependencies. TypeScript's readonly is not. It makes the type system lie: the annotation claims immutability but cannot enforce it once the value crosses a boundary you don't control.
Use const for bindings, as const for literals, and discipline (don't mutate parameters) for the rest. Don't annotate types with readonly -- it creates false confidence and forces escape hatches.
array.map/filter/reduce over for loops.export async function fetchWidget(id: string): Promise<Widget> {
const response = await fetch(`/api/widgets/${id}`);
if (!response.ok) {
throw new Error(`fetch widget ${id} failed: ${response.status}`);
}
const data: unknown = await response.json();
return WidgetSchema.parse(data);
}
Prefer these builtins over hand-rolling equivalent types:
| Utility | Purpose |
|---|---|
Pick<T, K> | Select fields from T |
Omit<T, K> | Exclude fields from T |
Partial<T> | All fields optional |
Required<T> | All fields required |
Record<K, V> | Object with known key/value types |
Extract<U, T> | Extract members from union |
Exclude<U, T> | Remove members from union |
NonNullable<T> | Remove null and undefined |
ReturnType<F> | Function return type |
Parameters<F> | Function parameter types as tuple |
These flags must be enabled. They are not optional.
{
"compilerOptions": {
// Core strict umbrella (noImplicitAny, strictNullChecks, strictFunctionTypes,
// strictBindCallApply, strictPropertyInitialization, noImplicitThis, alwaysStrict)
"strict": true,
// Index signatures return T | undefined, not T. Forces handling the None case.
"noUncheckedIndexedAccess": true,
// Require `override` keyword for overridden methods.
"noImplicitOverride": true,
// Prevent fallthrough in switch (pairs with exhaustive matching).
"noFallthroughCasesInSwitch": true,
// Catch dead code.
"noUnusedLocals": true,
"noUnusedParameters": true,
// Force `import type` for type-only imports.
"verbatimModuleSyntax": true,
// Force bracket notation for index signature access.
"noPropertyAccessFromIndexSignature": true,
},
}
foo.test.ts alongside foo.ts.import type for type-only imports.// Bad: type information in the name
const userString = "alice@example.com";
const itemsArray = [1, 2, 3];
const configMap = new Map();
const rawResponse = await fetch(url);
const parsedUser = UserSchema.parse(data);
// Good: the type system tells you what it is
const email = "alice@example.com";
const items = [1, 2, 3];
const config = new Map();
const response = await fetch(url);
const user = UserSchema.parse(data);
When refining a value, shadow it. The old binding disappears and can't be used by mistake:
// Bad: rawUser is still in scope, someone will use it
const rawUser = await response.json();
const parsedUser = UserSchema.parse(rawUser);
// Good: shadow so the unrefined value is inaccessible
function parseUser(data: unknown): User {
const user = UserSchema.parse(data);
return user;
}
// Good: in sequential scopes, reuse the name
const data: unknown = await response.json();
const user = UserSchema.parse(data);
// `data` is still in scope but the intent is clear --
// `user` is the refined value you work with from here.
// Bad: compiler can't distinguish these strings
function sendEmail(to: string, from: string, subject: string): void {
/* ... */
}
sendEmail(subject, from, to); // Compiles fine. Bug.
// Good: branded types make the compiler catch misuse
const EmailAddress = z.string().email().brand<"EmailAddress">();
type EmailAddress = z.infer<typeof EmailAddress>;
const Subject = z.string().min(1).brand<"Subject">();
type Subject = z.infer<typeof Subject>;
function sendEmail(
to: EmailAddress,
from: EmailAddress,
subject: Subject,
): void {
/* ... */
}
sendEmail(subject, from, to); // Compile error.
// Bad
type UUID = string & { readonly __brand: "UUID" };
type HTTPClient = {
/* ... */
};
type APIKey = string & { readonly __brand: "APIKey" };
type JSONParser = {
/* ... */
};
// Good
type Uuid = z.infer<typeof Uuid>;
type HttpClient = {
/* ... */
};
type ApiKey = z.infer<typeof ApiKey>;
type JsonParser = {
/* ... */
};
| Pattern | Why it's banned | Do this instead |
|---|---|---|
value as Type | Lies to the compiler | Parse with zod, narrow with control flow, or use satisfies |
value! | Asserts non-null without proof | Check for null, restructure to eliminate nullability |
any | Turns off the type system | Use unknown + zod parse |
interface Foo | Open to declaration merging | type Foo = { ... } |
{ name: string; age: number } inline | Anonymous, not reusable | Extract to type Person = { ... } |
@ts-ignore / @ts-expect-error | Hides real type errors | Fix the type error |
| Manual type guards as primary strategy | Hand-written, can drift from type | Zod schemas as source of truth |
eslint-disable / biome-ignore without justification | Suppresses safety checks | Fix the underlying issue |
const userString = ... | Hungarian notation; type info in name | const user = ... |
const rawX = ...; const parsedX = ... | Hungarian for lifecycle; leaves raw in scope | Shadow: parse into the same name |
fn(uuid: string) | Meaning in name, not type | fn(id: Uuid) -- newtype pattern |
UUID, HTTPClient, APIKey | All-caps acronyms are unreadable | Uuid, HttpClient, ApiKey |
readonly fields, Readonly<T>, ReadonlyArray<T> | Cooperative, not enforced across boundaries | Use const bindings, as const literals, discipline |
exactOptionalPropertyTypes | Libraries and JSON don't respect the distinction | Omit from tsconfig; validate with zod at boundaries |