writing-react-ts-code
Writing React/TypeScript Code
This is the authoritative standard for all React/TypeScript code. Every file you write or modify must conform to these rules. They are not suggestions.
1. TypeScript Strict Configuration
ESLint Flat Config
Use typescript-eslint with strict + type-checked presets, plus additional rules. Some rules use warnInDevModeErrorInProd() — warn during development, error in CI/production.
Key enforced rules (beyond the strict preset):
// Zero tolerance for type escape hatches
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/ban-ts-comment': 'error',
// Forbid vague types
'@typescript-eslint/no-empty-object-type': 'error', // {} banned
'@typescript-eslint/no-unsafe-function-type': 'error', // Function banned
'@typescript-eslint/no-wrapper-object-types': 'error', // Object/String/Number banned
'@typescript-eslint/no-restricted-types': ['error', { // object banned
types: { object: { message: 'Use Record<string, unknown> or a specific interface' } }
}],
// Enforce explicit, predictable code
'@typescript-eslint/strict-boolean-expressions': ['error', {
allowString: false,
allowNumber: false,
allowNullableObject: true,
allowNullableBoolean: true,
allowNullableString: false,
allowNullableNumber: false,
allowAny: false,
}],
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
'@typescript-eslint/switch-exhaustiveness-check': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'@typescript-eslint/consistent-type-assertions': ['error', {
assertionStyle: 'as',
objectLiteralTypeAssertions: 'never',
}],
'@typescript-eslint/explicit-function-return-type': ['error', {
allowExpressions: true,
allowTypedFunctionExpressions: true,
allowHigherOrderFunctions: true,
allowDirectConstAssertionInArrowFunctions: true,
allowConciseArrowFunctionExpressionsStartingWithVoid: true,
allowFunctionsWithoutTypeParameters: true,
allowIIFEs: true,
}],
// Code quality
eqeqeq: 'error',
'prefer-const': 'error',
'no-console': warnInDevModeErrorInProd(),
// React hooks (from next/core-web-vitals + explicit)
'react-hooks/exhaustive-deps': 'error',
'react-hooks/rules-of-hooks': 'error',
// Import organization
'import/order': [warnInDevModeErrorInProd(), {
'newlines-between': 'always',
alphabetize: { order: 'asc', caseInsensitive: true },
groups: ['builtin', 'external', 'internal', ['parent', 'sibling', 'index'], 'type'],
pathGroups: [{ pattern: '@/**', group: 'internal', position: 'before' }],
}],
'unused-imports/no-unused-imports': warnInDevModeErrorInProd(),
// Custom: process.env banned in src/ (except NODE_ENV) — use env.ts
'local-rules/no-process-env-in-src': ['error', { allow: ['NODE_ENV'] }],
Relaxed rules for vendored components (src/components/ui/**, src/components/kbar/**): all strict type rules, boolean safety, and vague type rules are turned off. These are generated/vendored code — do not manually enforce rules on them.
tsconfig.json
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"paths": { "@/*": ["./src/*"] }
}
}
2. Type Safety Rules
Forbidden Types
These types destroy type safety. Never use them.
| Forbidden | Use Instead | Why |
|---|---|---|
any |
unknown, explicit union, constrained generic |
any disables the type checker entirely |
{} |
Record<string, unknown> |
{} matches anything except null/undefined |
object |
specific interface or Record<string, unknown> |
object says nothing about shape |
Function |
(args: X) => Y explicit signature |
Function accepts any callable with no checking |
! (non-null assertion) |
proper null check, optional chaining, or narrowing | ! lies to the compiler — the value might be null |
as on object literals |
assign to a typed variable or use satisfies |
objectLiteralTypeAssertions: 'never' — object literals must be type-checked structurally |
@ts-ignore / @ts-expect-error |
fix the type error or use targeted ESLint disable | ban-ts-comment is error — don't bypass the compiler |
Type Safety Hierarchy
Each level requires justification to reach the next.
| Level | Approach | When |
|---|---|---|
| Preferred | Full typing: interfaces, generics, Zod schemas | All new code |
| Migration only | as cast with comment explaining why |
Migrating untyped code, temporary |
| Suppression only | // eslint-disable-next-line @typescript-eslint/no-unsafe-* with comment |
Third-party lib with broken types, after all alternatives exhausted |
| Forbidden | any, blanket @ts-ignore, empty catch {} |
Never |
React Component Generics
Use constrained generics, not any. The type system only works if you give it information.
// WRONG — kills type checking for the entire array
const items: ReactElement<any>[] = [];
// RIGHT — preserves type checking
const items: ReactElement<Record<string, unknown>>[] = [];
// WRONG — any props accepted
type Props = ComponentProps<any>;
// RIGHT — constrained to a specific element
type Props = ComponentProps<'button'>;
Third-Party Library Type Strategy
When a library has weak or missing types, try these in order. Stop at the first one that works.
- Official
@typespackages —pnpm add -D @types/library-name - Community typing packages — search npm for types
- Write own type definitions —
declare module 'library-name' { ... }in a.d.tsfile - Module augmentation for incomplete types:
// types/library-name.d.ts declare module 'library-name' { interface ExistingInterface { missingProperty: string; } } - Runtime assertion pattern with Result:
function parseLibraryResponse(raw: unknown): Result<LibraryData, ValidationError> { const parsed = libraryDataSchema.safeParse(raw); if (!parsed.success) { return { success: false, error: { message: parsed.error.message } }; } return { success: true, data: parsed.data }; } - Last resort — targeted ESLint disable with explanation:
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- library X exports untyped config, tracked in PROJ-1234 const config = libraryFunction();
3. Result Pattern
Every operation that can fail returns a Result. No exceptions for expected failures.
Core Types
// src/lib/result.ts
export type Result<T, E> =
| { readonly success: true; readonly data: T }
| { readonly success: false; readonly error: E };
Layer-Specific Types
// API layer — src/lib/api/api.types.ts
type ApiError = {
code: ErrorCode; // 'VALIDATION_ERROR', 'UNAUTHORIZED', etc.
message: string;
fields?: FieldErrors; // Field-specific validation errors
status?: number;
details?: unknown;
};
type ApiResult<T> = Result<T, ApiError>;
// Store layer — defined per store
type StoreError = Readonly<{
message: string;
details?: unknown;
}>;
type StoreResult<T> = Result<T, StoreError>;
// Store error state for UI display
type StoreErrorState = Readonly<{
error: StoreError;
errorMessageForUser: string;
}>;
Mapping Between Layers
Never leak API errors into the UI. Map explicitly at the store boundary.
function mapApiResultToStoreResult<T>(apiResult: ApiResult<T>): StoreResult<T> {
if (apiResult.success) {
return { success: true, data: apiResult.data };
}
return {
success: false,
error: {
message: apiResult.error.message,
details: apiResult.error,
},
};
}
Always Check Before Access
// WRONG — TypeScript will stop you, but don't even try
const data = result.data;
// RIGHT — narrow first
if (result.success) {
const data = result.data; // TypeScript knows this is T
} else {
const error = result.error; // TypeScript knows this is E
}
Decision Table: Result vs Throw
| Situation | Mechanism | Rationale |
|---|---|---|
| Network request fails | Result<T, ApiError> |
Expected — the caller must handle it |
| Validation fails | Result<T, ValidationError> |
Expected — show feedback to user |
| JSON parsing fails | Result<T, ParseError> |
Expected — external data can be malformed |
| Array index out of bounds in logic | throw Error |
Programming error — this is a bug |
| Switch hits impossible case | throw Error |
Invariant violation — this is a bug |
| Required config missing at startup | throw Error |
Fail fast — app cannot run |
| Third-party lib that throws | Catch and wrap in Result |
Normalize to our error-handling model |
4. Error Handling Strategy
Errors flow through layers. Each layer has a single responsibility.
Layer Architecture
API layer → catches exceptions → returns ApiResult<T>
Store layer → maps ApiResult → sets StoreError state
Component layer → reads store state → renders error UI or data
Form layer → reads error.fields → maps to field-level errors
Rendering crash → React ErrorBoundary → catches and displays fallback
API Boundary
Catch everything. Return ApiResult<T>. No raw exceptions escape. The template provides apiRequestWrapper in src/lib/api/ which handles fetch, Zod validation, auth token injection, and 401 auto-refresh.
// Simplified pattern — the real apiRequestWrapper handles more (auth, refresh, etc.)
async function apiRequestWrapper<T>(
request: () => Promise<Response>,
schema: ZodSchema<T>
): Promise<ApiResult<T>> {
try {
const response = await request();
if (!response.ok) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: await response.text(),
status: response.status,
},
};
}
const json: unknown = await response.json();
const parsed = schema.safeParse(json);
if (!parsed.success) {
return {
success: false,
error: {
code: 'VALIDATION_ERROR',
message: `Response validation failed: ${parsed.error.message}`,
status: response.status,
},
};
}
return { success: true, data: parsed.data };
} catch (error: unknown) {
return {
success: false,
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Unknown network error',
},
};
}
}
Store Boundary
Map ApiResult to StoreResult. Set StoreErrorState with a user-friendly message. Store actions return StoreResult<T> so callers can react.
// Inside a Zustand store action
fetchProducts: async (): Promise<StoreResult<readonly Product[]>> => {
set({ loading: true, error: null });
const apiResult = await productsApi.getAll();
const result = mapApiResultToStoreResult(apiResult);
if (!result.success) {
set({
loading: false,
error: {
error: result.error,
errorMessageForUser: 'Failed to load products',
},
});
return result;
}
set({ products: result.data, loading: false });
return result;
},
Component Boundary
Components render based on store state. Error boundaries catch rendering crashes. Conditional rendering handles store errors via StoreErrorState.
export function ProductList(): JSX.Element {
const { products, error, loading } = useProductStore();
if (loading) {
return <ProductListSkeleton />;
}
if (error !== null) {
return <ErrorMessage message={error.errorMessageForUser} />;
}
return (
<ul>
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</ul>
);
}
Form Errors
Zod schemas validate. React Hook Form manages state. Server errors map to fields.
const formSchema = z.object({
email: z.string().email('Invalid email address'),
name: z.string().min(1, 'Name is required'),
});
type FormData = z.infer<typeof formSchema>;
export function UserForm(): JSX.Element {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
});
async function onSubmit(data: FormData): Promise<void> {
const result = await userApi.create(data);
if (!result.success) {
// Map server field errors back to form fields
if (result.error.fields != null) {
for (const [field, messages] of Object.entries(result.error.fields)) {
form.setError(field as keyof FormData, { message: messages.join(', ') });
}
return;
}
toast.error(result.error.message);
return;
}
toast.success('User created');
}
return <form onSubmit={form.handleSubmit(onSubmit)}>{/* fields */}</form>;
}
5. Dynamic Data Handling
All data from outside the application boundary is untrusted. Validate immediately.
Zod for Everything External
// API responses — define schema, validate on receipt
const productSchema = z.object({
id: z.string().uuid(),
name: z.string(),
price: z.number().positive(),
});
// JSON.parse always returns unknown — validate immediately
function parseJsonConfig(raw: string): Result<Config, ValidationError> {
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
return { success: false, error: { message: 'Invalid JSON' } };
}
const result = configSchema.safeParse(parsed);
if (!result.success) {
return { success: false, error: { message: result.error.message } };
}
return { success: true, data: result.data };
}
The safeParse + handleZodValidationOrThrow Pattern
function handleZodValidationOrThrow<T>(
result: z.SafeParseReturnType<unknown, T>,
context: string
): T {
if (result.success) {
return result.data;
}
// This is a programming error — the data should have been valid
throw new Error(
`Zod validation failed in ${context}: ${result.error.message}`
);
}
// Use when invalid data is a bug (internal config, known-good sources)
const config = handleZodValidationOrThrow(
configSchema.safeParse(rawConfig),
'app configuration'
);
What Gets Validated
| Source | Method | On Failure |
|---|---|---|
| API responses | schema.safeParse() wrapped in Result |
Return error to caller |
JSON.parse output |
schema.safeParse() wrapped in Result |
Return error to caller |
| Environment variables | t3-env with Zod schemas | Build fails immediately |
| Form inputs | Zod + React Hook Form | Show validation errors |
| Route params | Zod schema in loader/page | Redirect to 404 |
6. Component Patterns
Standard Component Structure
interface ProductCardProps {
readonly product: Product;
readonly onAddToCart: (productId: string) => Promise<void>;
}
export function ProductCard({
product,
onAddToCart,
}: ProductCardProps): JSX.Element {
const [isAdding, setIsAdding] = useState<boolean>(false);
async function handleAddToCart(): Promise<void> {
setIsAdding(true);
try {
await onAddToCart(product.id);
toast.success('Added to cart');
} catch (error: unknown) {
toast.error('Failed to add to cart');
} finally {
setIsAdding(false);
}
}
return (
<div>
<h3>{product.name}</h3>
<p>{formatPrice(product.price)}</p>
<Button onClick={handleAddToCart} disabled={isAdding}>
{isAdding ? 'Adding...' : 'Add to Cart'}
</Button>
</div>
);
}
Rules
- Named exports only. No
export default. Exception: Next.jspage.tsxandlayout.tsxfiles require default exports. - Explicit return types on all exported functions:
JSX.Element,Promise<void>, etc. readonlyon all props interface properties. Props are immutable input.- Explicit types for state.
useState<boolean>(false), notuseState(false). - Async event handlers return
Promise<void>and handle their own errors inside. Never let a rejected promise go unhandled. - No inline object/array creation in JSX props when it causes unnecessary re-renders. Extract to
useMemoor a constant outside the render.
App Router Pattern
Route files are thin wrappers. Logic lives in components and stores.
// src/app/users/page.tsx — thin route file (default export required by Next.js)
export default function UsersPage(): JSX.Element {
return <UserList />;
}
// src/components/UserList.tsx — all logic here (named export)
export function UserList(): JSX.Element {
const { users, loading, error, fetchUsers } = useUserStore();
// loading states, error handling, data rendering
}
7. Naming Conventions
Naming is a navigation system, not a style preference.
| Kind | Convention | Example |
|---|---|---|
| Component | PascalCase.tsx |
ProductCard.tsx |
| Store | kebab-case-store.ts |
product-store.ts |
| API client | kebab-case-api.ts |
product-api.ts |
| Types | kebab-case.ts |
product-types.ts |
| Schemas | kebab-case-schema.ts |
product-schema.ts |
| Hooks | useKebabCase.ts |
useProductSearch.ts |
| Utils | kebab-case.ts |
format-price.ts |
| Constants | SCREAMING_SNAKE_CASE inside files |
MAX_RETRY_COUNT |
8. Import Organization
Imports are grouped and alphabetized. Enforced by ESLint, not by discipline.
// 1. Builtin (node:)
import { readFile } from 'node:fs/promises';
// 2. External packages
import { z } from 'zod';
import { create } from 'zustand';
// 3. Internal (@/ alias)
import { apiRequestWrapper } from '@/lib/api/api-client';
import { productSchema } from '@/schemas/product-schema';
// 4. Parent/sibling/index (grouped together)
import { formatPrice } from '../utils/format-price';
import { ProductImage } from './ProductImage';
// 5. Type imports (always last)
import type { Product } from '@/types/product-types';
Path alias: @/* maps to ./src/*. Never use deep relative paths like ../../../lib/utils. Use the alias.
9. Environment Variables
All Access Through t3-env
// src/env.ts
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
API_SECRET: z.string().min(1),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
API_SECRET: process.env.API_SECRET,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});
Rules
process.envis banned insrc/by a custom ESLint rule (scripts/eslint/rules/no-process-env-in-src). The only exception isNODE_ENV. All other access goes throughenv.ts.- Build fails if any required variable is missing or fails Zod validation. No silent fallback to
undefined. - Server vs client: Only
NEXT_PUBLIC_prefixed variables are available in browser code. Server variables stay on the server. - Always import from
@/env:// WRONG — ESLint error const url = process.env.NEXT_PUBLIC_API_URL; // RIGHT — typed, validated, guaranteed to exist import { env } from '@/env'; const url = env.NEXT_PUBLIC_API_URL;
10. Code Style
Prettier Configuration
{
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"plugins": ["prettier-plugin-tailwindcss"]
}
Rules
prefer-consteverywhere. Useletonly when reassignment is necessary.===enforced viaeqeqeqESLint rule.==is banned.??over||for nullish defaults.||coerces empty strings and0to falsy.?.over&&chains for optional property access.- Explicit return types on all exported functions and any function longer than a few lines. TypeScript may infer for simple local one-liners.
- Early returns for error/edge cases. Keep the happy path unindented and linear.
// WRONG — deeply nested
function process(input: string | null): Result<Data, ProcessError> {
if (input !== null) {
if (input.length > 0) {
// actual logic buried in nesting
}
}
}
// RIGHT — early returns, flat structure
function process(input: string | null): Result<Data, ProcessError> {
if (input === null) {
return { success: false, error: { message: 'Input is null' } };
}
if (input.length === 0) {
return { success: false, error: { message: 'Input is empty' } };
}
// actual logic at top-level indentation
return { success: true, data: transform(input) };
}
Boolean Expressions
strict-boolean-expressions is enabled. No truthy checks on strings or numbers. Nullable booleans are allowed.
// WRONG — fails strict-boolean-expressions
if (name) { ... }
if (count) { ... }
if (items.length) { ... }
// RIGHT — explicit comparisons
if (name !== '') { ... }
if (count > 0) { ... }
if (items.length > 0) { ... }
if (value !== null && value !== undefined) { ... }
// OK — nullable booleans are allowed (allowNullableBoolean: true)
if (isEnabled) { ... } // where isEnabled: boolean | null
Console Usage
no-consoleuseswarnInDevModeErrorInProd()— warns in development, errors in CI/production builds.- Never commit
console.logcalls except for startup/shutdown messages in server code.
11. Security
These are non-negotiable. Violations are bugs, not style issues.
| Rule | Rationale |
|---|---|
No raw process.env access |
All env vars go through t3-env with Zod validation |
No dangerouslySetInnerHTML |
XSS vector. If truly unavoidable, sanitize with DOMPurify first |
| HttpOnly cookies for auth tokens | Prevents JavaScript access to sensitive tokens |
credentials: 'include' on authenticated requests |
Required for cookie-based auth; configure CORS on backend to match |
| Input validation via Zod before processing | All user input, route params, query strings — validate first, use after |
| No string concatenation for SQL/HTML | Use parameterized queries and template components |
| No secrets in client code | NEXT_PUBLIC_ prefix exposes to browser bundle |
| Server components for sensitive logic | Keep API keys and DB queries out of client bundles |
12. API Code Generation
Zod schemas for API responses are auto-generated from OpenAPI/Swagger specs. Do not write them by hand.
- Generate:
pnpm api:gen <feature>reads the Swagger spec and outputs Zod schemas intoapi/generated/directories - Use in API clients: Import generated schemas and pass to
apiRequestWrapperfor runtime validation - Single source of truth: Backend OpenAPI spec defines the contract; generated Zod schemas enforce it at runtime
Feature Directory Structure
src/mfes/{domain}/
app.tsx # Main React component
index.ts # single-spa lifecycles export
stores/
{domain}-store.ts # Zustand store with Result pattern
api/
{domain}-api.ts # API client using apiRequestWrapper
generated/ # Auto-generated Zod schemas (pnpm api:gen)
components/ # Domain-specific components (optional)
More from quick-brown-foxxx/coding_rules_ts
testing-react-ts
React/TypeScript testing with Vitest and Playwright: philosophy, component tests, integration tests, e2e tests, test infrastructure. Use when writing tests or setting up test infrastructure. ALWAYS load this for working with tests in react.
2managing-state
State management patterns: Zustand stores, React Context, URL state, form state. Use when creating stores, managing component state, or choosing state patterns. ALWAYS load this skill when writing any react code!
2building-ui-components
UI component patterns: shadcn/ui, component organization, variant system. Use when building or modifying React UI components. ALWAYS load this skill when working with react code.
2building-api-clients
Type-safe API clients: HTTP client, Zod validation, Result pattern, Swagger codegen. Use when creating API integrations or working with backend services. ALWAYS load this skill when working with APIs in react clients
2setting-up-nextjs-projects
Next.js project setup: directory structure, configuration, tooling, environment validation. Use when bootstrapping new projects or understanding project layout.
2