Intermediate70 min read

TypeScript Generics: Creating Functions with Dynamic Typing

Learn how to create functions with dynamic typing using TypeScript generics, similar to createContext. Master generic functions, type inference, and how to build flexible, reusable functions.

Topics Covered:

GenericsGeneric FunctionsType InferenceGeneric ConstraintscreateContext PatternType Parameters

Prerequisites:

  • TypeScript Basics for React Developers
  • TypeScript Interfaces: Comprehensive Guide

Video Tutorial

Overview

Generics allow you to create functions, components, and types that work with multiple types while maintaining type safety. They enable dynamic typing where the type is determined by how the function is called. This tutorial covers how to create generic functions like React's createContext, understand type inference, use generic constraints, and build flexible, reusable functions that adapt to different types.

Understanding Generics

Generics are TypeScript's way of creating reusable code that works with multiple types. They allow you to write functions and types that are flexible yet type-safe. What are Generics: • Type parameters that make code flexible • Written with angle brackets: <T> • Type is determined when function is called • Maintains type safety • Enables code reuse Why Use Generics: • Write code once, use with many types • Maintain type safety • Better than using 'any' • Enables type inference • Common in libraries and frameworks Basic Syntax: • function name<T>(param: T): T • T is a type variable • Can use any name (T, U, V, Item, Value, etc.) • Type is inferred from usage

Code Example:
// Function without generics (not flexible)
function getFirst(arr: number[]): number {
  return arr[0];
}

const num = getFirst([1, 2, 3]); // ✅ Works
const str = getFirst(["a", "b", "c"]); // ❌ Error: expects number[]

// Function with generics (flexible and type-safe)
function getFirst<T>(arr: T[]): T {
  return arr[0];
}

const num = getFirst([1, 2, 3]); // TypeScript infers T as number
const str = getFirst(["a", "b", "c"]); // TypeScript infers T as string
const user = getFirst([{ id: 1, name: "Alice" }]); // TypeScript infers T as { id: number; name: string }

// Explicit type parameter (optional, usually inferred)
const num = getFirst<number>([1, 2, 3]);
const str = getFirst<string>(["a", "b", "c"]);

// Multiple type parameters
function pair<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const p1 = pair("hello", 42); // [string, number]
const p2 = pair(1, true); // [number, boolean]

// Generic function for React state
function useState<T>(initial: T): [T, (value: T) => void] {
  // Implementation
  return [initial, () => {}];
}

const [count, setCount] = useState(0); // T inferred as number
const [name, setName] = useState(""); // T inferred as string
const [user, setUser] = useState<User | null>(null); // T is User | null

Generics use type parameters (like <T>) to make functions work with multiple types. TypeScript infers the type from usage, maintaining type safety while providing flexibility.

How createContext Works with Generics

React's createContext is a perfect example of a generic function. It creates a context that can hold any type, and TypeScript infers the type from the initial value you provide. createContext Pattern: • Generic function: createContext<T> • Takes initial value of type T • Returns Context with type T • Type is inferred from initial value • Can be explicitly typed Understanding the Pattern: • Function accepts type parameter • Type can be inferred or explicit • Return type uses the type parameter • Maintains type safety throughout Why This Pattern is Powerful: • One function works with any type • Type safety is maintained • TypeScript knows the exact type • Autocomplete works perfectly

Code Example:
// How createContext is defined (simplified)
function createContext<T>(defaultValue: T): Context<T> {
  // Implementation
  return context;
}

// Usage with type inference
const ThemeContext = createContext('light');
// TypeScript infers: Context<string>

const CountContext = createContext(0);
// TypeScript infers: Context<number>

const UserContext = createContext(null);
// TypeScript infers: Context<null> (not very useful)

// Usage with explicit type
const ThemeContext = createContext<'light' | 'dark' | undefined>(undefined);
// Type is explicitly: Context<'light' | 'dark' | undefined>

const UserContext = createContext<User | null>(null);
// Type is: Context<User | null>

// Complete example
type Theme = 'light' | 'dark';

const ThemeContext = createContext<Theme | undefined>(undefined);

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
  
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context; // Type is Theme
}

// Creating your own createContext-like function
function createTypedContext<T>() {
  const Context = createContext<T | undefined>(undefined);
  
  const useTypedContext = () => {
    const context = useContext(Context);
    if (context === undefined) {
      throw new Error('Context must be used within Provider');
    }
    return context; // Type is T
  };
  
  return [Context, useTypedContext] as const;
}

// Usage
const [UserContext, useUser] = createTypedContext<User>();

function UserProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  
  return (
    <UserContext.Provider value={user || undefined}>
      {children}
    </UserContext.Provider>
  );
}

function UserDisplay() {
  const user = useUser(); // Type is User
  return <div>{user.name}</div>;
}

createContext uses generics to create a context for any type. The type is inferred from the initial value or can be explicitly provided. This pattern enables type-safe context usage throughout your app.

Creating Your Own Generic Functions

You can create your own generic functions following the same patterns. This enables you to build flexible, reusable utilities. Creating Generic Functions: • Add type parameter in angle brackets • Use type parameter in function signature • Type is inferred from arguments • Can have constraints • Can have default types Common Patterns: • Identity function (returns same type) • Array utilities • Object utilities • API functions • React hooks • Utility functions Best Practices: • Use descriptive type parameter names • Let TypeScript infer when possible • Use constraints when needed • Document complex generics

Code Example:
// Simple generic function
function identity<T>(value: T): T {
  return value;
}

const num = identity(42); // number
const str = identity("hello"); // string
const user = identity({ id: 1, name: "Alice" }); // { id: number; name: string }

// Generic array function
function getLast<T>(arr: T[]): T | undefined {
  return arr[arr.length - 1];
}

const lastNum = getLast([1, 2, 3]); // number | undefined
const lastStr = getLast(["a", "b"]); // string | undefined

// Generic object function
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", age: 30 };
const name = getProperty(user, "name"); // string
const id = getProperty(user, "id"); // number

// Generic API function
async function fetchData<T>(url: string): Promise<T> {
  const response = await fetch(url);
  return response.json();
}

interface User {
  id: number;
  name: string;
}

const user = await fetchData<User>("/api/user"); // Type is User
const users = await fetchData<User[]>("/api/users"); // Type is User[]

// Generic React hook
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });
  
  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };
  
  return [storedValue, setValue];
}

// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const [count, setCount] = useLocalStorage<number>('count', 0);
const [user, setUser] = useLocalStorage<User | null>('user', null);

// Generic utility function
function createCache<T>() {
  const cache = new Map<string, T>();
  
  return {
    get(key: string): T | undefined {
      return cache.get(key);
    },
    set(key: string, value: T): void {
      cache.set(key, value);
    },
    has(key: string): boolean {
      return cache.has(key);
    }
  };
}

const stringCache = createCache<string>();
stringCache.set('user1', 'Alice');
const user = stringCache.get('user1'); // string | undefined

const numberCache = createCache<number>();
numberCache.set('count', 42);
const count = numberCache.get('count'); // number | undefined

Create generic functions by adding type parameters. Use them for arrays, objects, API calls, React hooks, and utilities. TypeScript infers types from usage, maintaining type safety.

Type Inference in Generic Functions

TypeScript's type inference is powerful with generics. Understanding how inference works helps you write better generic functions. How Type Inference Works: • TypeScript infers type from arguments • Inference flows from arguments to return type • Can infer from multiple arguments • Sometimes needs explicit type • Inference works left to right When Inference Works: • Simple generic functions • Functions with clear argument types • When type can be determined from usage • Most common cases When You Need Explicit Types: • Complex generic functions • When inference is ambiguous • When you want specific type • Union types that need narrowing

Code Example:
// Type inference from single argument
function getValue<T>(value: T): T {
  return value;
}

const str = getValue("hello"); // T inferred as string
const num = getValue(42); // T inferred as number

// Type inference from multiple arguments
function combine<T, U>(first: T, second: U): [T, U] {
  return [first, second];
}

const pair = combine("hello", 42); // [string, number]
const pair2 = combine(1, true); // [number, boolean]

// Inference from array
function getFirst<T>(arr: T[]): T | undefined {
  return arr[0];
}

const first = getFirst([1, 2, 3]); // number | undefined
const firstStr = getFirst(["a", "b"]); // string | undefined

// Inference from object
function getKeys<T extends object>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

const user = { id: 1, name: "Alice" };
const keys = getKeys(user); // ("id" | "name")[]

// When explicit type is needed
function createContext<T>(defaultValue: T): Context<T> {
  return context;
}

// Inference might not work as expected
const ThemeContext = createContext(null); // Context<null> - not useful

// Explicit type is better
const ThemeContext = createContext<'light' | 'dark' | undefined>(undefined);
// Context<'light' | 'dark' | undefined>

// Inference in React hooks
function useState<T>(initial: T): [T, (value: T) => void] {
  // Implementation
}

const [count, setCount] = useState(0); // T is number
const [name, setName] = useState(""); // T is string

// Explicit type when needed
const [user, setUser] = useState<User | null>(null); // T is User | null

// Inference with constraints
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

const strLen = getLength("hello"); // T inferred as string
const arrLen = getLength([1, 2, 3]); // T inferred as number[]
const objLen = getLength({ length: 5 }); // T inferred as { length: number }

TypeScript infers generic types from function arguments. Inference works automatically in most cases, but sometimes you need to provide explicit types for clarity or when inference is ambiguous.

Generic Constraints

Generic constraints limit what types can be used with a generic function. They ensure type parameters have certain properties or extend certain types. What are Constraints: • Limit possible types for generic parameter • Use extends keyword • Ensure type has certain properties • Enable access to properties • Maintain type safety Common Constraint Patterns: • extends object - must be object • extends keyof T - must be key of T • extends { property: type } - must have property • extends SomeInterface - must implement interface • Multiple constraints with intersection Use Cases: • Access properties safely • Ensure type compatibility • Create type-safe utilities • Build on existing types

Code Example:
// Constraint: T must have length property
function getLength<T extends { length: number }>(item: T): number {
  return item.length;
}

getLength("hello"); // ✅ string has length
getLength([1, 2, 3]); // ✅ array has length
getLength({ length: 5 }); // ✅ object has length
getLength(42); // ❌ number doesn't have length

// Constraint: K must be key of T
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

const user = { id: 1, name: "Alice", age: 30 };
getProperty(user, "name"); // ✅ "name" is key of user
getProperty(user, "email"); // ❌ "email" is not a key

// Constraint: T must extend User interface
interface User {
  id: number;
  name: string;
}

function getUserName<T extends User>(user: T): string {
  return user.name; // Safe because T extends User
}

const admin = { id: 1, name: "Alice", role: "admin" };
getUserName(admin); // ✅ admin has id and name

// Multiple constraints
function process<T extends User & { active: boolean }>(user: T): string {
  if (user.active) {
    return `${user.name} is active`;
  }
  return `${user.name} is inactive`;
}

// Constraint with default type
function createArray<T = string>(length: number, value: T): T[] {
  return Array(length).fill(value);
}

const strings = createArray(5, "hello"); // string[]
const numbers = createArray(5, 0); // number[]
const explicit = createArray<number>(5, 0); // number[]

// Constraint for React component props
interface BaseProps {
  className?: string;
}

function createComponent<T extends BaseProps>(
  Component: React.ComponentType<T>
) {
  return (props: T) => {
    return <Component {...props} className={`base ${props.className || ""}`} />;
  };
}

// Constraint ensuring type has certain methods
interface Serializable {
  toJSON(): string;
}

function serialize<T extends Serializable>(item: T): string {
  return item.toJSON();
}

class User implements Serializable {
  constructor(public name: string) {}
  toJSON() {
    return JSON.stringify({ name: this.name });
  }
}

serialize(new User("Alice")); // ✅ User implements Serializable

Generic constraints use extends to limit possible types. They ensure type parameters have required properties, enabling safe property access and maintaining type safety.

Advanced Generic Patterns

Advanced generic patterns enable powerful type-safe abstractions. These patterns are used in libraries and complex applications. Advanced Patterns: • Generic classes • Generic interfaces • Conditional generics • Mapped types with generics • Recursive generics • Generic utility types Common Advanced Patterns: • Factory functions • Builder patterns • Repository patterns • API client patterns • State management patterns Best Practices: • Keep generics simple when possible • Use constraints appropriately • Document complex generics • Test with multiple types • Consider readability

Code Example:
// Generic class
class Box<T> {
  private value: T;
  
  constructor(value: T) {
    this.value = value;
  }
  
  getValue(): T {
    return this.value;
  }
  
  setValue(value: T): void {
    this.value = value;
  }
}

const stringBox = new Box("hello");
const numberBox = new Box(42);

// Generic interface
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  save(entity: T): Promise<T>;
  delete(id: string): Promise<void>;
}

class UserRepository implements Repository<User> {
  async findById(id: string): Promise<User | null> {
    // Implementation
    return null;
  }
  
  async findAll(): Promise<User[]> {
    return [];
  }
  
  async save(user: User): Promise<User> {
    return user;
  }
  
  async delete(id: string): Promise<void> {
    // Implementation
  }
}

// Factory function pattern
function createApiClient<T>(baseUrl: string) {
  return {
    async get(endpoint: string): Promise<T> {
      const response = await fetch(`${baseUrl}${endpoint}`);
      return response.json();
    },
    
    async post(endpoint: string, data: unknown): Promise<T> {
      const response = await fetch(`${baseUrl}${endpoint}`, {
        method: 'POST',
        body: JSON.stringify(data)
      });
      return response.json();
    }
  };
}

const userApi = createApiClient<User>('/api');
const user = await userApi.get('/user/1'); // Type is User

// Generic React hook factory
function createUseState<T>(initialValue: T) {
  return function useTypedState() {
    return useState<T>(initialValue);
  };
}

const useCount = createUseState(0);
const [count, setCount] = useCount(); // count is number

// Generic with conditional types
type NonNullable<T> = T extends null | undefined ? never : T;

function requireValue<T>(value: T): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error('Value cannot be null or undefined');
  }
  return value as NonNullable<T>;
}

// Generic utility type
type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;

interface User {
  id: string;
  name: string;
  email: string;
}

type UserUpdate = Optional<User, 'id'>;
// Result: { name?: string; email?: string; } (id is omitted)

// Recursive generic
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

type Nested = {
  user: {
    profile: {
      name: string;
    };
  };
};

type ReadonlyNested = DeepReadonly<Nested>;
// All nested properties are readonly

// Generic React context pattern (like createContext)
function createTypedContext<T>() {
  const Context = createContext<T | undefined>(undefined);
  
  const Provider = ({ value, children }: { 
    value: T; 
    children: React.ReactNode 
  }) => {
    return <Context.Provider value={value}>{children}</Context.Provider>;
  };
  
  const useTypedContext = () => {
    const context = useContext(Context);
    if (context === undefined) {
      throw new Error('Context must be used within Provider');
    }
    return context;
  };
  
  return { Context, Provider, useTypedContext };
}

// Usage
const { Provider: ThemeProvider, useTypedContext: useTheme } = 
  createTypedContext<'light' | 'dark'>();

function App() {
  return (
    <ThemeProvider value="light">
      <Component />
    </ThemeProvider>
  );
}

function Component() {
  const theme = useTheme(); // Type is 'light' | 'dark'
  return <div className={theme}>Content</div>;
}

Advanced generic patterns include generic classes, interfaces, factory functions, and utility types. These patterns enable powerful, type-safe abstractions used in libraries and complex applications.

Real-World Examples: Building Generic Utilities

Let's build real-world generic utilities that you can use in your React applications. These examples demonstrate practical uses of generics. Practical Examples: • Generic API hooks • Generic form handlers • Generic state management • Generic data transformers • Generic validation functions Building Blocks: • Start with simple generics • Add constraints as needed • Use type inference when possible • Provide explicit types for clarity • Test with multiple types

Code Example:
// Generic API hook
function useFetch<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then((data: T) => {
        setData(data);
        setLoading(false);
      })
      .catch((err: Error) => {
        setError(err);
        setLoading(false);
      });
  }, [url]);
  
  return { data, loading, error };
}

// Usage
interface User {
  id: number;
  name: string;
}

const { data: user } = useFetch<User>('/api/user'); // data is User | null

// Generic form handler
function useForm<T extends Record<string, unknown>>(initialValues: T) {
  const [values, setValues] = useState<T>(initialValues);
  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});
  
  const setValue = <K extends keyof T>(key: K, value: T[K]) => {
    setValues(prev => ({ ...prev, [key]: value }));
  };
  
  const setError = <K extends keyof T>(key: K, error: string) => {
    setErrors(prev => ({ ...prev, [key]: error }));
  };
  
  const reset = () => {
    setValues(initialValues);
    setErrors({});
  };
  
  return { values, errors, setValue, setError, reset };
}

// Usage
const { values, setValue } = useForm({
  name: '',
  email: '',
  age: 0
});

setValue('name', 'Alice'); // ✅ Type-safe
setValue('invalid', 'value'); // ❌ Error

// Generic data transformer
function transformArray<T, U>(
  arr: T[],
  transformer: (item: T) => U
): U[] {
  return arr.map(transformer);
}

const numbers = [1, 2, 3];
const strings = transformArray(numbers, n => String(n)); // string[]

// Generic validation
function createValidator<T>(
  rules: Record<keyof T, (value: T[keyof T]) => boolean>
) {
  return (data: T): Partial<Record<keyof T, string>> => {
    const errors: Partial<Record<keyof T, string>> = {};
    
    for (const key in rules) {
      if (!rules[key](data[key])) {
        errors[key] = `${String(key)} is invalid`;
      }
    }
    
    return errors;
  };
}

// Usage
const validateUser = createValidator<User>({
  id: (value) => typeof value === 'number' && value > 0,
  name: (value) => typeof value === 'string' && value.length > 0
});

const errors = validateUser({ id: 1, name: 'Alice' }); // {}

// Generic context creator (like createContext)
function createContext<T>(defaultValue: T) {
  const Context = React.createContext<T>(defaultValue);
  
  const Provider = ({ 
    value, 
    children 
  }: { 
    value: T; 
    children: React.ReactNode 
  }) => {
    return <Context.Provider value={value}>{children}</Context.Provider>;
  };
  
  const useContext = () => {
    return React.useContext(Context);
  };
  
  return { Context, Provider, useContext };
}

// Usage
const { Provider: ThemeProvider, useContext: useTheme } = 
  createContext<'light' | 'dark'>('light');

function App() {
  return (
    <ThemeProvider value="dark">
      <Component />
    </ThemeProvider>
  );
}

function Component() {
  const theme = useTheme(); // Type is 'light' | 'dark'
  return <div className={theme}>Content</div>;
}

// Generic cache
function createCache<T>() {
  const cache = new Map<string, T>();
  
  return {
    get(key: string): T | undefined {
      return cache.get(key);
    },
    set(key: string, value: T): void {
      cache.set(key, value);
    },
    clear(): void {
      cache.clear();
    }
  };
}

const userCache = createCache<User>();
userCache.set('user1', { id: 1, name: 'Alice' });
const user = userCache.get('user1'); // User | undefined

Real-world generic utilities include API hooks, form handlers, data transformers, validators, and context creators. These demonstrate practical uses of generics in React applications, providing type safety and code reuse.

Conclusion

Generics enable you to create functions with dynamic typing that maintain type safety. Functions like createContext use generics to work with any type while TypeScript infers and enforces the correct types. Use generics for reusable functions, add constraints when needed, and leverage type inference. Remember: generics make your code flexible and type-safe - write once, use with many types. Start simple, add constraints as needed, and let TypeScript's inference do the work when possible.