All posts

TypeScript Tips for Better Developer Experience

TypeScriptDeveloper ExperienceProductivity

TypeScript has become the standard for JavaScript development, but many developers only scratch the surface of what it can do. Here are some advanced techniques and configurations that will supercharge your development experience.

Utility Types: Your Secret Weapon

TypeScript's built-in utility types can save you tons of boilerplate code and make your types more expressive.

Pick and Omit for Interface Slicing

interface User {
  id: string
  name: string
  email: string
  password: string
  createdAt: Date
  updatedAt: Date
}

// ✅ Create focused types
type PublicUser = Omit<User, 'password'>
type UserCredentials = Pick<User, 'email' | 'password'>
type CreateUserRequest = Omit<User, 'id' | 'createdAt' | 'updatedAt'>

Partial and Required for Flexibility

// Make all properties optional for updates
function updateUser(id: string, updates: Partial<User>) {
  // Implementation
}

// Ensure specific fields are always present
type UserWithRequiredEmail = User & Required<Pick<User, 'email'>>

Advanced Type Patterns

Discriminated Unions for Better Error Handling

type ApiResponse<T> =
  | { success: true; data: T }
  | { success: false; error: string }

function handleResponse<T>(response: ApiResponse<T>) {
  if (response.success) {
    // TypeScript knows response.data exists
    console.log(response.data)
  } else {
    // TypeScript knows response.error exists
    console.error(response.error)
  }
}

Template Literal Types for API Routes

type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
type ApiEndpoint = '/users' | '/posts' | '/comments'
type ApiRoute = `${HttpMethod} ${ApiEndpoint}`

// Now you have type-safe route definitions
const routes: Record<ApiRoute, Function> = {
  'GET /users': getUsersHandler,
  'POST /users': createUserHandler,
  // TypeScript will error if you miss any routes!
}

Conditional Types for Smart APIs

type ApiCall<T extends string> = T extends `GET ${infer Path}`
  ? { method: 'GET'; path: Path }
  : T extends `POST ${infer Path}`
  ? { method: 'POST'; path: Path; body: unknown }
  : never

// Usage
type GetUsers = ApiCall<'GET /users'> // { method: 'GET'; path: '/users' }
type CreateUser = ApiCall<'POST /users'> // { method: 'POST'; path: '/users'; body: unknown }

Configuration That Actually Helps

Strict TypeScript Config

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

Path Mapping for Cleaner Imports

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"],
      "@/components/*": ["src/components/*"],
      "@/utils/*": ["src/utils/*"],
      "@/types/*": ["src/types/*"]
    }
  }
}

React + TypeScript Best Practices

Component Props with Variants

import { cva, type VariantProps } from 'class-variance-authority'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
)

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)

Generic Components with Constraints

interface SelectOption {
  value: string
  label: string
}

interface SelectProps<T extends SelectOption> {
  options: T[]
  value?: T['value']
  onChange: (value: T['value']) => void
  getOptionLabel?: (option: T) => string
}

function Select<T extends SelectOption>({
  options,
  value,
  onChange,
  getOptionLabel = (option) => option.label,
}: SelectProps<T>) {
  // Implementation with full type safety
}

Type Guards and Narrowing

Custom Type Guards

function isUser(obj: unknown): obj is User {
  return (
    typeof obj === 'object' &&
    obj !== null &&
    'id' in obj &&
    'name' in obj &&
    'email' in obj
  )
}

// Usage
function processUserData(data: unknown) {
  if (isUser(data)) {
    // TypeScript knows data is User here
    console.log(data.name)
  }
}

Assertion Functions

function assertIsUser(obj: unknown): asserts obj is User {
  if (!isUser(obj)) {
    throw new Error('Object is not a valid User')
  }
}

// Usage
function handleUserData(data: unknown) {
  assertIsUser(data)
  // TypeScript knows data is User for the rest of the function
  return data.email
}

IDE Integration Tips

JSDoc for Better Intellisense

/**
 * Calculates the total price including tax
 * @param basePrice - The base price before tax
 * @param taxRate - Tax rate as decimal (0.1 for 10%)
 * @returns The total price including tax
 * @example
 * ```typescript
 * const total = calculateTotal(100, 0.1); // 110
 * ```
 */
function calculateTotal(basePrice: number, taxRate: number): number {
  return basePrice * (1 + taxRate)
}

Satisfies Operator for Better Inference

const config = {
  apiUrl: 'https://api.example.com',
  timeout: 5000,
  retries: 3,
} satisfies Record<string, string | number>

// TypeScript infers the exact type, not just Record<string, string | number>
// config.apiUrl is string, config.timeout is number

Performance Considerations

Lazy Type Loading

// Instead of importing everything
import { ComponentA, ComponentB, ComponentC } from './components'

// Import only what you need
import type { ComponentAProps } from './components/ComponentA'
const ComponentA = lazy(() => import('./components/ComponentA'))

Type-only Imports

// ✅ Type-only import (no runtime cost)
import type { User } from './types'

// ❌ Regular import (includes runtime code)
import { User } from './types'

The Bottom Line

TypeScript is incredibly powerful when you leverage its advanced features. These patterns and configurations will:

  1. Catch more bugs at compile time
  2. Improve IDE experience with better autocomplete
  3. Make refactoring safer and easier
  4. Create more maintainable codebases

The key is to gradually adopt these patterns. Start with the utility types and strict configuration, then work your way up to the more advanced patterns as you get comfortable.

Which TypeScript features have had the biggest impact on your development workflow? I'd love to hear about your favorite patterns and tips!