DEV Community

Jan
Jan

Posted on

TypeScript Generics Propagation in useOptimistic react hook

Summary

Generics enable reusable, type-safe code by parameterizing types, and their propagation ensures that these parameterized types are correctly applied or inferred in various contexts, such as function calls, object compositions, or React components (like the useOptimistic hook example). These benefits come at a cost: generics can pose a steep learning curve and increased complexity, particularly with nested structures, potentially leading to ambiguity in code.

1. What Are TypeScript Generics?

Generics allow you to define functions, interfaces, or classes that work with a variety of types while preserving type safety. Instead of hardcoding a specific type, you use a placeholder (e.g., T) that is specified when the generic is used.

Example:

function identity<T>(value: T): T {
  return value;
}

const num = identity<number>(42); // T is number
const str = identity<string>("hello"); // T is string
Enter fullscreen mode Exit fullscreen mode

Here, T is a generic type parameter that propagates through the function, ensuring the input and output types are the same.

If you’ve read the official TypeScript docs and explored more digestible examples, you might still find the following useOptimistic React hook example challenging:

const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[], string>(
  messages,
  (state, newMessage) => [
    ...state,
    {
      text: newMessage,
      sending: true,
    },
  ]
);
Enter fullscreen mode Exit fullscreen mode

Can you clearly articulate what <Message[], string> means?

If so, great job—stop reading here! 👍

If not, let’s dive deeper.

Type Inference with useOptimistic

TypeScript’s type inference can sometimes eliminate the need for explicit generic annotations. For example, if the messages array is already typed as Message[] and the newMessage parameter is annotated as string, TypeScript can infer the generics:

const [optimisticMessages, addOptimisticMessage] = useOptimistic(messages, (state, newMessage: string) => [
  ...state,
  { text: newMessage, sending: true },
]);
Enter fullscreen mode Exit fullscreen mode

Here, TypeScript infers T as Message[] from messages and A as string from newMessage. Explicit generics (<Message[], string>) are necessary only when TypeScript cannot infer types (e.g., with ambiguous or untyped inputs) or when you want to enforce specific types.

useOptimistic with User-Friendly Typing

To build a mental model, consider this mock implementation:

function useOptimistic<StateType, UpdateType>(
  initialState: StateType,
  applyUpdate: (currentState: StateType, update: UpdateType) => StateType
): [StateType, (update: UpdateType) => void] {
  const [state, setState] = React.useState(initialState);

  function addOptimisticUpdate(update: UpdateType) {
    setState((prevState) => applyUpdate(prevState, update));
  }

  return [state, addOptimisticUpdate];
}
Enter fullscreen mode Exit fullscreen mode

This shows how StateType and UpdateType are used in the hook.

useOptimistic Generic Signature

The previous example is verbose. Let’s simplify it using T and A as generic parameters:

function useOptimistic<T, A>(state: T, updateFn: (state: T, action: A) => T): [T, (action: A) => void];
Enter fullscreen mode Exit fullscreen mode

(The real-world useOptimistic signature can be found in the React source code .)

Returning to the hook usage:

const [optimisticMessages, addOptimisticMessage] = useOptimistic<Message[], string>(
  // ...
);
Enter fullscreen mode Exit fullscreen mode

Now, the meaning of <Message[], string> becomes clearer:

  • T: The type of the state (e.g., Message[]).
    • This defines the type of the optimistic state being managed.
    • Here, optimisticMessages is typed as an array of Message objects.
  • A: The type of the action (e.g., string).
    • This defines the type of data passed to the updater function (addOptimisticMessage).
    • In this case, string represents the text of a new message used to create an optimistic message.

 2. Mechanics of Generics Propagation

Generics propagation occurs when a generic type is passed or inferred through a chain of operations, such as:

  • Function calls
  • Return types
  • Object or array manipulations
  • Component props or hooks in React

The TypeScript compiler tracks the generic type (T, A, etc.) and ensures it is consistently applied or inferred based on usage. Propagation can happen explicitly (via type annotations) or implicitly (via type inference).

In React, generics propagation is common in hooks, components, and utilities, ensuring type safety for props, state, or actions. Let’s analyze the useOptimistic hook:

1. Explicit Type Specification:

  • The generic <Message[], string> is explicitly provided when calling useOptimistic.
  • T is set to Message[], meaning the state (optimisticMessages) and the first argument to the update function (state) are Message[].
  • A is set to string, meaning the action (newMessage) and the argument to addOptimisticMessage are string.

2. Propagation to State:

  • The hook returns optimisticMessages as T (Message[]), so optimisticMessages is typed as Message[].
  • This ensures that operations on optimisticMessages (e.g., mapping or rendering) treat it as an array of Message objects.

3. Propagation to Update Function:

  • The update function (state: T, newMessage: A) => T becomes (state: Message[], newMessage: string) => Message[].
  • The generic T propagates to state and the return type, ensuring the function returns a Message[].
  • The generic A propagates to newMessage, ensuring it’s a string.

4. Propagation to Action Handler:

  • The hook returns addOptimisticMessage as (action: A) => void, which becomes (action: string) => void.
  • When addOptimisticMessage("new message") is called, TypeScript ensures the argument is a string, propagating the A type.

Generics propagation ensures that:

  • The state (optimisticMessages) remains Message[] throughout the component lifecycle.
  • The action (addOptimisticMessage) only accepts string inputs.
  • The update function produces a valid Message[], preventing type mismatches (e.g., adding an invalid object to the array).

 3. Conclusion

Understanding TypeScript basics is a prerequisite before diving into generics. By mastering generics propagation, we can:

  • Write reusable, type-safe code.
  • Ensure hooks and components handle data consistently.
  • Catch errors early through TypeScript’s type checking.

Common pitfalls are overly complex generic constraints that make code harder to read or maintain, incorrect type assumptions that lead to type errors (e.g., assuming a generic type is more specific than it is). Simplifying generics and leveraging TypeScript’s inference can often reduce complexity while maintaining safety.

Top comments (0)