typescript

JustinFirsching's avatarfrom JustinFirsching

TypeScript and React/TSX coding conventions and patterns

⭐0stars🔀0forks📁View on GitHub🕐Updated Jan 10, 2026

When & Why to Use This Skill

This Claude skill establishes a comprehensive framework for TypeScript and React/TSX development, focusing on type safety, maintainability, and modern architectural patterns. It enforces strict coding standards—such as prohibiting the 'any' type and favoring named exports—while providing optimized templates for functional components, custom hooks, and robust error handling to ensure high-quality software engineering.

Use Cases

  • Refactoring Legacy Code: Converting older React class components into modern functional components using hooks and standardized TypeScript interfaces.
  • Implementing Type-Safe API Layers: Utilizing Zod for runtime validation and discriminated unions to handle complex API response states safely.
  • Standardizing Project Architecture: Organizing large-scale applications with consistent file structures, barrel exports, and strict tsconfig.json configurations.
  • Enhancing State Management: Integrating TanStack Query (React Query) for efficient server-state management and data fetching patterns.
  • Improving Code Review Quality: Using the skill as a benchmark to identify anti-patterns like default exports or improper type usage during the development process.
nametypescript
descriptionTypeScript and React/TSX coding conventions and patterns

TypeScript Skill

Use this skill when working with TypeScript or TSX code.

Required Practices

These rules must always be followed:

No any Type

// Bad - loses all type safety
function process(data: any): any {
  return data.value;
}

const result: any = fetchData();

// Good - use proper types
function process(data: { value: string }): string {
  return data.value;
}

// Good - use unknown when type is truly unknown
function parseJson(input: string): unknown {
  return JSON.parse(input);
}

// Good - use generics for flexible types
function first<T>(items: T[]): T | undefined {
  return items[0];
}

Named Exports Only (No Default Exports)

// Bad - default exports
export default function Button() { ... }
export default class UserService { ... }

// Good - named exports
export function Button() { ... }
export class UserService { ... }

// Good - multiple exports from a file
export { Button, ButtonProps };
export { UserService, UserRepository };

Why: Named exports provide better refactoring support, clearer imports, and prevent naming inconsistencies.

// With named exports, the import must match
import { Button } from './Button';  // Clear what you're importing

// With default exports, you can use any name
import Btn from './Button';  // Confusing - is this really Button?

Functional Components with Hooks

// Bad - class components
class UserProfile extends React.Component<Props, State> {
  state = { loading: true };
  
  componentDidMount() {
    this.fetchUser();
  }
  
  render() {
    return <div>{this.state.user?.name}</div>;
  }
}

// Good - functional component with hooks
export function UserProfile({ userId }: UserProfileProps) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetchUser(userId).then(setUser).finally(() => setLoading(false));
  }, [userId]);

  if (loading) return <Spinner />;
  return <div>{user?.name}</div>;
}

Type Patterns

Interface vs Type

// Use interface for object shapes (extendable)
interface User {
  id: string;
  name: string;
  email: string;
}

interface AdminUser extends User {
  permissions: string[];
}

// Use type for unions, primitives, and complex types
type Status = 'pending' | 'active' | 'inactive';
type UserId = string;
type UserOrNull = User | null;
type Callback = (user: User) => void;

Props Types

// Define props interface with component
interface ButtonProps {
  label: string;
  onClick: () => void;
  variant?: 'primary' | 'secondary';
  disabled?: boolean;
}

export function Button({ 
  label, 
  onClick, 
  variant = 'primary',
  disabled = false,
}: ButtonProps) {
  return (
    <button 
      className={`btn-${variant}`}
      onClick={onClick}
      disabled={disabled}
    >
      {label}
    </button>
  );
}

Children Props

import { ReactNode } from 'react';

interface CardProps {
  title: string;
  children: ReactNode;
}

export function Card({ title, children }: CardProps) {
  return (
    <div className="card">
      <h2>{title}</h2>
      {children}
    </div>
  );
}

Generic Components

interface ListProps<T> {
  items: T[];
  renderItem: (item: T) => ReactNode;
  keyExtractor: (item: T) => string;
}

export function List<T>({ items, renderItem, keyExtractor }: ListProps<T>) {
  return (
    <ul>
      {items.map((item) => (
        <li key={keyExtractor(item)}>{renderItem(item)}</li>
      ))}
    </ul>
  );
}

// Usage
<List
  items={users}
  renderItem={(user) => <span>{user.name}</span>}
  keyExtractor={(user) => user.id}
/>

React Patterns

Custom Hooks

// Extract reusable logic into custom hooks
export function useUser(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetchUser(userId)
      .then(setUser)
      .catch(setError)
      .finally(() => setLoading(false));
  }, [userId]);

  return { user, loading, error };
}

// Usage
export function UserProfile({ userId }: { userId: string }) {
  const { user, loading, error } = useUser(userId);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  if (!user) return <NotFound />;
  
  return <div>{user.name}</div>;
}

Event Handlers

interface FormProps {
  onSubmit: (data: FormData) => void;
}

export function LoginForm({ onSubmit }: FormProps) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(e.target.value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" value={email} onChange={handleEmailChange} />
      <input 
        type="password" 
        value={password} 
        onChange={(e) => setPassword(e.target.value)} 
      />
      <button type="submit">Login</button>
    </form>
  );
}

Conditional Rendering

export function UserStatus({ user }: { user: User | null }) {
  // Early return for null case
  if (!user) {
    return <GuestBanner />;
  }

  // Ternary for simple conditions
  return (
    <div>
      <span>{user.isAdmin ? 'Admin' : 'User'}</span>
      
      {/* Logical AND for optional elements */}
      {user.unreadCount > 0 && (
        <Badge count={user.unreadCount} />
      )}
    </div>
  );
}

Utility Types

// Partial - all properties optional
type UserUpdate = Partial<User>;

// Required - all properties required
type CompleteUser = Required<User>;

// Pick - select specific properties
type UserPreview = Pick<User, 'id' | 'name'>;

// Omit - exclude specific properties
type UserWithoutId = Omit<User, 'id'>;

// Record - key-value map
type UserRoles = Record<string, 'admin' | 'user' | 'guest'>;

// ReturnType - infer return type
type FetchResult = ReturnType<typeof fetchUser>;

// Parameters - infer parameter types
type FetchParams = Parameters<typeof fetchUser>;

Error Handling

// Define error types
interface ApiError {
  code: string;
  message: string;
  field?: string;
}

// Type guard for errors
function isApiError(error: unknown): error is ApiError {
  return (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    'message' in error
  );
}

// Usage
try {
  await createUser(data);
} catch (error) {
  if (isApiError(error)) {
    showFieldError(error.field, error.message);
  } else {
    showGenericError('An unexpected error occurred');
  }
}

File Organization

src/
├── components/
│   ├── Button/
│   │   ├── Button.tsx        # Component
│   │   ├── Button.test.tsx   # Tests
│   │   └── index.ts          # Re-export
│   └── index.ts              # Barrel export
├── hooks/
│   ├── useUser.ts
│   └── index.ts
├── services/
│   ├── api.ts
│   └── userService.ts
├── types/
│   ├── user.ts
│   └── index.ts
└── utils/
    ├── format.ts
    └── validate.ts

Barrel Exports

// components/Button/index.ts
export { Button } from './Button';
export type { ButtonProps } from './Button';

// components/index.ts
export { Button } from './Button';
export { Card } from './Card';
export { Modal } from './Modal';

Style Reference

For additional TypeScript conventions, refer to:

Strict TypeScript Config

Always enable strict mode in tsconfig.json:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "exactOptionalPropertyTypes": true,
    "noImplicitOverride": true
  }
}

These catch bugs at compile time that would otherwise surface at runtime.

Discriminated Unions

Use discriminated unions for type-safe branching:

// Result type for operations that can fail
type Result<T, E = Error> =
  | { success: true; data: T }
  | { success: false; error: E };

function handleResult(result: Result<User>) {
  if (result.success) {
    console.log(result.data.name);  // TypeScript knows data exists
  } else {
    console.error(result.error);    // TypeScript knows error exists
  }
}

// API response states
type ApiState<T> =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: T }
  | { status: 'error'; error: string };

function renderState(state: ApiState<User>) {
  switch (state.status) {
    case 'idle': return null;
    case 'loading': return <Spinner />;
    case 'success': return <UserCard user={state.data} />;
    case 'error': return <Error message={state.error} />;
  }
}

Exhaustive Switch Statements

Ensure all cases are handled at compile time:

type Status = 'pending' | 'active' | 'cancelled';

function getLabel(status: Status): string {
  switch (status) {
    case 'pending': return 'Pending';
    case 'active': return 'Active';
    case 'cancelled': return 'Cancelled';
    default:
      // Compile error if a case is missing
      const _exhaustive: never = status;
      return _exhaustive;
  }
}

Runtime Validation with Zod

Use Zod for validating external data (API responses, form input):

import { z } from 'zod';

// Define schema
const UserSchema = z.object({
  id: z.number(),
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(['admin', 'user', 'guest']),
  createdAt: z.string().datetime(),
});

// Infer TypeScript type from schema
type User = z.infer<typeof UserSchema>;

// Validate at runtime
function parseUser(data: unknown): User {
  return UserSchema.parse(data);  // Throws ZodError if invalid
}

// Safe parsing (no throw)
function tryParseUser(data: unknown): User | null {
  const result = UserSchema.safeParse(data);
  return result.success ? result.data : null;
}

// Validate API response
async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();
  return UserSchema.parse(data);  // Validated!
}

Const Assertions

Use as const for literal types:

// Array of literal types
const ROLES = ['admin', 'user', 'guest'] as const;
type Role = typeof ROLES[number];  // 'admin' | 'user' | 'guest'

// Object with literal values
const HTTP_STATUS = {
  OK: 200,
  CREATED: 201,
  BAD_REQUEST: 400,
  NOT_FOUND: 404,
} as const;

type StatusCode = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];  // 200 | 201 | 400 | 404

// Config objects
const CONFIG = {
  apiUrl: '/api',
  timeout: 5000,
  retries: 3,
} as const;
// CONFIG.timeout is type 5000, not number

Data Fetching with React Query

Use TanStack Query for server state:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// Query hook
export function useUser(id: string) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id),
    staleTime: 5 * 60 * 1000,  // 5 minutes
  });
}

// Mutation hook
export function useUpdateUser() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data, variables) => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['user', variables.id] });
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

// Usage in component
export function UserProfile({ id }: { id: string }) {
  const { data: user, isLoading, error } = useUser(id);
  const updateUser = useUpdateUser();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  if (!user) return <NotFound />;

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      updateUser.mutate({ id, name: newName });
    }}>
      {/* form content */}
    </form>
  );
}