Steve Kinney

Function Overloads for Flexible APIs

Function overloads in TypeScript let you define multiple type signatures for a single implementation, creating APIs that feel intuitive and adapt to how developers actually want to use them. In React applications, this translates to components and hooks that “just work” regardless of whether you pass a string, an object, or different combinations of arguments. We’ll explore how to design flexible, type-safe APIs that make your components feel as polished as React’s built-ins.

Think of overloads as a way to say “this function can be called in these specific ways, and TypeScript will know exactly what to expect in each case.” Instead of forcing users into a single rigid API, you can support multiple calling patterns while maintaining full type safety.

The Problem: Rigid APIs vs. Real Usage

Consider a simple Button component that accepts either a string or a configuration object:

// ❌ Awkward: forces everyone into the same pattern
interface ButtonProps {
  config: string | { text: string; variant: 'primary' | 'secondary'; disabled?: boolean };
}

function Button({ config }: ButtonProps) {
  if (typeof config === 'string') {
    return <button>{config}</button>;
  }

  return (
    <button disabled={config.disabled} className={`btn-${config.variant}`}>
      {config.text}
    </button>
  );
}

// Usage feels clunky
<Button config="Click me" />
<Button config={{ text: "Submit", variant: "primary", disabled: false }} />

This works, but it feels unnatural. Users have to wrap simple strings in a config object, and TypeScript can’t provide specific autocomplete based on how you’re calling the component.

Function Overloads: Multiple Signatures, One Implementation

Function overloads let you define multiple “call signatures” that map to the same underlying function. Here’s how we’d improve that Button component:

// ✅ Multiple call signatures
function Button(text: string): JSX.Element;
function Button(config: { text: string; variant: 'primary' | 'secondary'; disabled?: boolean }): JSX.Element;
function Button(textOrConfig: string | { text: string; variant: 'primary' | 'secondary'; disabled?: boolean }): JSX.Element {
  if (typeof textOrConfig === 'string') {
    return <button>{textOrConfig}</button>;
  }

  const { text, variant = 'primary', disabled = false } = textOrConfig;
  return (
    <button disabled={disabled} className={`btn-${variant}`}>
      {text}
    </button>
  );
}

// Usage feels natural
<Button text="Click me" />
<Button config={{ text: "Submit", variant: "primary" }} />

Now TypeScript knows that when you pass a string, you get a basic button. When you pass an object, it expects the full configuration interface and provides autocomplete for variant and disabled.

The implementation signature (the actual function body) must be compatible with all the overload signatures. TypeScript will only show users the overload signatures in IntelliSense, not the implementation signature.

Real-World Pattern: Conditional Props

Here’s a more sophisticated example—a Modal component that changes its props based on whether it’s controlled or uncontrolled:

interface BaseModalProps {
  title: string;
  children: React.ReactNode;
}

interface ControlledModalProps extends BaseModalProps {
  isOpen: boolean;
  onClose: () => void;
}

interface UncontrolledModalProps extends BaseModalProps {
  defaultOpen?: boolean;
}

// Overload signatures
function Modal(props: ControlledModalProps): JSX.Element;
function Modal(props: UncontrolledModalProps): JSX.Element;
function Modal(props: ControlledModalProps | UncontrolledModalProps): JSX.Element {
  // Type guards help us narrow the implementation
  if ('isOpen' in props) {
    // TypeScript knows this is ControlledModalProps
    const { isOpen, onClose, title, children } = props;
    return isOpen ? (
      <div className="modal">
        <div className="modal-content">
          <button onClick={onClose}>×</button>
          <h2>{title}</h2>
          {children}
        </div>
      </div>
    ) : null;
  }

  // TypeScript knows this is UncontrolledModalProps
  const { defaultOpen = false, title, children } = props;
  const [isOpen, setIsOpen] = useState(defaultOpen);

  return isOpen ? (
    <div className="modal">
      <div className="modal-content">
        <button onClick={() => setIsOpen(false)}>×</button>
        <h2>{title}</h2>
        {children}
      </div>
    </div>
  ) : null;
}

Users get different TypeScript experiences based on their intent:

// Controlled usage - TypeScript requires isOpen and onClose
<Modal
  isOpen={showModal}
  onClose={() => setShowModal(false)}
  title="Settings"
>
  <SettingsForm />
</Modal>

// Uncontrolled usage - TypeScript knows onClose doesn't exist
<Modal
  defaultOpen={true}
  title="Welcome"
>
  <WelcomeMessage />
</Modal>

Hook Overloads: Flexible State Management

Custom hooks benefit enormously from overloads. Here’s a useLocalStorage hook that returns different shapes based on whether you want loading states:

// Overload 1: Simple usage
function useLocalStorage<T>(
  key: string,
  defaultValue: T,
): [T, (value: T | ((prev: T) => T)) => void];

// Overload 2: With loading state
function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  includeLoading: true,
): [T, (value: T | ((prev: T) => T)) => void, boolean];

// Implementation
function useLocalStorage<T>(
  key: string,
  defaultValue: T,
  includeLoading?: boolean,
):
  | [T, (value: T | ((prev: T) => T)) => void]
  | [T, (value: T | ((prev: T) => T)) => void, boolean] {
  const [state, setState] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : defaultValue;
    } catch {
      return defaultValue;
    }
  });

  const [loading, setLoading] = useState(includeLoading ? true : false);

  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        const newValue = value instanceof Function ? value(state) : value;
        setState(newValue);
        window.localStorage.setItem(key, JSON.stringify(newValue));
        setLoading(false);
      } catch (error) {
        console.error(`Error setting localStorage key "${key}":`, error);
      }
    },
    [key, state],
  );

  useEffect(() => {
    if (includeLoading) {
      setLoading(false);
    }
  }, [includeLoading]);

  // TypeScript uses the overloads to determine the return type
  if (includeLoading) {
    return [state, setValue, loading];
  }

  return [state, setValue];
}

Now users get exactly the API they expect:

// Simple usage - no loading state
const [user, setUser] = useLocalStorage('user', null);

// With loading - includes loading boolean
const [settings, setSettings, isLoading] = useLocalStorage('settings', {}, true);

if (isLoading) {
  return <div>Loading settings...</div>;
}

Advanced Pattern: Method Overloads

You can also use overloads for object methods, which is particularly useful for builder patterns or fluent interfaces:

class QueryBuilder<T> {
  private conditions: string[] = [];

  // Overload for single condition
  where(field: keyof T, operator: string, value: any): QueryBuilder<T>;
  // Overload for object condition
  where(conditions: Partial<Record<keyof T, any>>): QueryBuilder<T>;
  // Implementation
  where(
    fieldOrConditions: keyof T | Partial<Record<keyof T, any>>,
    operator?: string,
    value?: any,
  ): QueryBuilder<T> {
    if (typeof fieldOrConditions === 'object') {
      // Handle object conditions
      Object.entries(fieldOrConditions).forEach(([field, val]) => {
        this.conditions.push(`${String(field)} = ${JSON.stringify(val)}`);
      });
    } else {
      // Handle single condition
      this.conditions.push(`${String(fieldOrConditions)} ${operator} ${JSON.stringify(value)}`);
    }
    return this;
  }

  build(): string {
    return this.conditions.join(' AND ');
  }
}

// Usage supports both patterns
const query1 = new QueryBuilder<User>()
  .where('age', '>', 18)
  .where('status', '=', 'active')
  .build();

const query2 = new QueryBuilder<User>().where({ age: 25, status: 'active' }).build();

Gotchas and Best Practices

Implementation Must Handle All Cases

Your implementation function must handle every possible combination defined in your overloads:

// ❌ Implementation doesn't handle all overload cases
function processData(data: string): string;
function processData(data: number[]): number;
function processData(data: string | number[]): string | number {
  if (typeof data === 'string') {
    return data.toUpperCase();
  }
  // Forgot to handle number[] case!
  // This will cause runtime errors
}

// ✅ Complete implementation
function processData(data: string): string;
function processData(data: number[]): number;
function processData(data: string | number[]): string | number {
  if (typeof data === 'string') {
    return data.toUpperCase();
  }

  return data.reduce((sum, num) => sum + num, 0);
}

Order Matters

TypeScript checks overloads from top to bottom and uses the first match:

// ❌ Order can cause unexpected behavior
function format(value: any): string; // Too broad - matches everything
function format(value: number): string; // Never reached!
function format(value: string): string; // Never reached!

// ✅ Most specific first
function format(value: number): string;
function format(value: string): string;
function format(value: any): string;

Don’t Overuse Overloads

Sometimes a simple union type or generic is clearer than overloads:

// ❌ Overloads for something simple
function getId(user: User): string;
function getId(id: string): string;
function getId(userOrId: User | string): string {
  return typeof userOrId === 'string' ? userOrId : userOrId.id;
}

// ✅ Simple union is clearer
function getId(userOrId: User | string): string {
  return typeof userOrId === 'string' ? userOrId : userOrId.id;
}

Use overloads when you want to provide fundamentally different return types or when the calling patterns are distinct enough to warrant separate interfaces.

When to Reach for Overloads

Function overloads shine when you have:

  • Different return types based on inputs: Like useLocalStorage returning different tuple lengths
  • Mutually exclusive prop patterns: Controlled vs. uncontrolled components
  • Progressive enhancement: Basic usage vs. advanced configuration
  • Backward compatibility: Supporting old API alongside new features

Overloads transform good TypeScript APIs into great ones. They let you support multiple usage patterns without sacrificing type safety, creating components and hooks that feel intuitive and adapt to how developers actually want to work. The key is finding the right balance—use them when they genuinely improve the developer experience, not just because you can.

When designed thoughtfully, overloaded functions become indistinguishable from React’s built-in APIs. Users don’t think about the overloads; they just use your API naturally and get the exact TypeScript experience they expect.

Last modified on .