Typing React Higher-Order Components (HOCs) in TypeScript — A Practical Guide
Introduction
Higher-Order Components (HOCs) remain a powerful pattern in React for reusing component logic. But as codebases grow, poorly typed HOCs become an Achilles' heel: they break inference, mask props, and create runtime surprises. This guide helps intermediate developers bring type-safety, clarity, and maintainability to HOCs using TypeScript. We'll cover common HOC shapes, how to preserve prop types and refs, implementing parameterized and generic HOCs, and strategies for migrating legacy HOCs to modern typed patterns.
You will learn how to build HOCs that correctly infer wrapped component props, avoid common pitfalls like excessive "any" usage, and interoperate with modern TypeScript compiler options. Practical examples include theming HOCs, data-fetch HOCs, withRef forwarding, and HOCs that inject props while preserving original component signatures. We'll also discuss advanced techniques like curried HOCs, conditional prop injection, compatibility with functional and class components, and performance considerations.
Throughout the article you'll find actionable code snippets, step-by-step instructions, and troubleshooting tips. Where relevant, the article references related TypeScript topics: utility types for manipulating function and constructor types, strict-null checks, and transpilation considerations. By the end you should be able to author robust, reusable HOCs that integrate smoothly into a typed React codebase and stand up to refactors.
Background & Context
HOCs are functions that take a component and return a new component with augmented behavior. In JavaScript this is straightforward, but TypeScript needs explicit contracts: what props does the wrapped component expect, which props are injected (and therefore removed from the required prop list), and how refs should be forwarded. Mistakes commonly lead to broken inference and the need for manual type assertions. Addressing these issues requires understanding TypeScript utility types and generics as well as React's type definitions.
Typing HOCs correctly helps your editor give accurate autocompletion, prevents runtime prop mismatches, and enables safe refactoring. You should also consider compiler options like strict null checking and module interop which affect how types behave in your project. For example, enabling strict null checks will change how optional injected props are typed and requires you to be explicit about possibly undefined values.
Key Takeaways
- Understand the HOC type shape and how to preserve wrapped component props
- Use generics and utility types to inject or remove props safely
- Forward refs and preserve component display names for debugging
- Migrate legacy HOCs with gradual typing and compiler safety checks
- Recognize how TypeScript options affect HOC typing and transpilation
Prerequisites & Setup
Before following the examples, ensure you have:
- TypeScript 4.1+ (recommended) and React 16.8+
- tsconfig configured for your project; enabling esModuleInterop can simplify default imports from some libraries
- Familiarity with React functional components, refs, and basic TypeScript generics
- A build setup that respects module transpilation rules; if you use Babel or ts-loader, consider implications of isolatedModules
Install types if needed:
npm install --save-dev typescript @types/react @types/react-dom
Now let's dive into practical HOC typing patterns.
Main Tutorial Sections
1) Basic HOC: Injecting Props Safely (with Generics)
Start with the simplest HOC: injecting a single prop like theme. The goal: accept any component that needs a theme, supply it, and return a component that requires the remaining props only.
import React from 'react'
type InjectedProps = { theme: string }
function withTheme<P extends object>(Component: React.ComponentType<P & InjectedProps>) {
return function WithTheme(props: Omit<P, keyof InjectedProps>) {
const theme = 'light'
// Type cast: props satisfies P without injected keys
return <Component {...(props as P)} theme={theme} />
}
}Key points: use P extends object, accept Component: ComponentType<P & InjectedProps>, and return a component whose props are Omit<P, keyof InjectedProps>. For Omit to work nicely across projects, ensure you use TS lib that includes it or define your own.
Related reading: utility types like ConstructorParameters
2) Preserving Wrapped Component Display Name and Static Methods
HOCs often strip static methods and display names. Preserve them for debugging and compatibility:
import hoistNonReactStatics from 'hoist-non-react-statics'
function withTheme<P extends object>(Wrapped: React.ComponentType<P & InjectedProps>) {
const Component = (props: Omit<P, keyof InjectedProps>) => <Wrapped {...(props as P)} theme={'light'} />
Component.displayName = `withTheme(${Wrapped.displayName || Wrapped.name || 'Component'})`
hoistNonReactStatics(Component, Wrapped)
return Component
}hoist-non-react-statics helps copy static members. Keep displayName for DevTools.
3) Forwarding Refs from HOCs
Forwarding refs is crucial for HOCs that wrap class components or DOM nodes. Use React.forwardRef:
type RefType = HTMLDivElement
function withForwardedRef<P extends object>(Wrapped: React.ComponentType<P & { innerRef?: React.Ref<RefType> }>) {
type Props = Omit<P, 'innerRef'>
const Forwarded = React.forwardRef<RefType, Props>((props, ref) => {
return <Wrapped {...(props as P)} innerRef={ref} />
})
Forwarded.displayName = `withForwardedRef(${Wrapped.displayName || Wrapped.name})`
return Forwarded
}Note how we typed forwardRef's generic parameters and protected innerRef. Using Omit ensures callers don't need to supply innerRef directly.
4) HOCs that Inject Optional Props and strictNullChecks
When injecting optional values, strictNullChecks matters. If an injected prop may be undefined, express that explicitly.
type InjectedMaybe = { user?: { id: string } | undefined }
function withUser<P extends object>(Wrapped: React.ComponentType<P & InjectedMaybe>) {
return (props: Omit<P, keyof InjectedMaybe>) => {
const user = undefined // maybe fetched later
return <Wrapped {...(props as P)} user={user} />
}
}If you enable strict null checks you must handle user possibly being undefined in the wrapped component.
5) Currying HOCs: Parameterized Factories
Many HOCs accept options and return a configured HOC. Use higher-kinded generics to keep types:
function withConfig<TOption extends string>(option: TOption) {
return function <P extends object>(Component: React.ComponentType<P & { option: TOption }>) {
return (props: Omit<P, 'option'>) => <Component {...(props as P)} option={option} />
}
}This preserves the literal type of option when used, making inference precise.
6) HOCs that Wrap Class Components and Constructor Types
If you wrap classes and need to interact with constructors or static members, utility types like ConstructorParameters and InstanceType become handy:
function wrapClassComponent<T extends new (...args: any[]) => React.Component<any, any>>(Ctor: T) {
type Inst = InstanceType<T>
// do something with Inst types
return Ctor
}See our guide on ConstructorParameters
7) Composing Multiple HOCs While Preserving Types
Composing HOCs naively leads to prop erosion. Build a typed composeHOCs utility that preserves inference:
function composeHOCs<T>(...hocs: Array<(c: any) => any>) {
return (Component: T) => hocs.reduceRight((acc, hoc) => hoc(acc), Component)
}This simple version uses any, but you can build a variadic generic composition utility. For most cases, ensure you apply HOCs in the correct order: injectors before consumers.
8) Dealing with "any" and Improving Inference: Utility Types
When you see any creeping in, use TypeScript helper types: Omit, Partial, Pick, and conditional types. Additionally, TypeScript provides ThisParameterType and OmitThisParameter which can help when dealing with methods or classic function bindings. For deeper details, check the deep dives on ThisParameterType
Example: converting a function prop that relies on this to a cleaner signature:
type Fn = (this: { value: number }, x: number) => void
type NoThis = OmitThisParameter<Fn>Using these utilities clarifies intent and prevents hidden this headaches in HOCs that wrap methods.
9) Interop and Declaration Files for Library HOCs
If you ship HOCs as a package, generate declaration files so consumers get typing. Configure declaration: true and optionally declarationMap: true. Our guide on generating declaration files explains setup and edge cases. Also be mindful of module interop to avoid import issues when publishing — see esModuleInterop.
10) Transpilation Safety and isolatedModules
If your project uses Babel or requires single-file transpilation, isolatedModules enforces constraints (no namespace imports, etc.). This affects how you author HOCs, especially those relying on type-only imports. Review isolatedModules for best practices and adjustments when migrating.
Advanced Techniques
Once you're comfortable with the basics, adopt advanced patterns: use conditional types to make injected props optional depending on input props, or leverage mapped types to auto-infer injected prop keys. For example, build an InferInjected type that extracts the injected keys from a HOC factory to statically verify that two composed HOCs don't conflict.
Optimize runtime by avoiding excessive wrapping layers: use composition utilities that apply multiple behaviors in a single wrapper when possible. For performance-sensitive components, measure re-renders and prefer React.memo for functional wrappers. If hooking into lifecycle-like behavior, prefer hooks-based composition (useX) where possible — HOCs still have value for cross-cutting concerns, but hooks can be easier to type and test.
When packaging HOCs, generate types (declaration) and validate them with TypeScript in CI. Tools that perform isolated transpilation (e.g., Babel-only pipelines) should validate types with a separate tsc check to prevent subtle runtime/typing mismatches.
Best Practices & Common Pitfalls
Do:
- Preserve wrapped props: avoid reinventing prop lists; use
Omit<P, keyof Injected>to remove injected keys - Forward refs when relevant using
React.forwardRefand maintain accurate generics - Keep HOCs small and single-responsibility; compose them for complex behavior
- Preserve static members via
hoist-non-react-staticsand set clear display names - Run type-only checks in CI and generate declaration files for libraries (generating declaration files)
Don't:
- Use
anyas a shortcut—it defeats the purpose of TypeScript - Assume optional injected props work without
strictNullChecks; test under strict null checks - Overwrap components creating deep wrapper chains that harm debugging and performance
Troubleshooting:
- If props inference is lost, explicitly annotate the generic parameter on the HOC or on the returned component to help TS infer correctly
- If your build uses Babel and types fail during development, ensure you run tsc in CI or enable isolatedModules compatible patterns
Real-World Applications
HOCs remain useful for cross-cutting concerns such as theming, permission gating, analytics, error boundaries, and legacy code integration. Example: a withFeatureFlag HOC injects isEnabled and can be used across many UI components without touching internals. Another example is wrapping class components with behaviors like telemetry or lifecycle logging, where you may need to preserve constructors and static methods — here utility types such as InstanceType
For library authors, ensure consumers get built types and seamless imports by paying attention to esModuleInterop and publishing declaration files as described earlier.
Conclusion & Next Steps
Typing HOCs in TypeScript requires intentional design: define what you inject, preserve the original API, forward refs, and prefer precise generics over any. Start by converting one HOC at a time, run tsc to catch regressions, and consider moving cross-cutting logic to hooks where appropriate. Next, explore TypeScript utility types in depth and consider reading guides on function/constructor utilities to further refine patterns.
Recommended next reads: deep dives into ThisParameterType
Enhanced FAQ
Q1: How do I type an HOC that adds props while preserving optional props on the wrapped component?
A1: Use generics and Omit to remove injected keys from the wrapped component's required props. If the wrapped component originally had optional props, their optional nature will remain in P. Example:
function withFeature<P extends object>(Comp: React.ComponentType<P & { flag: boolean }>) {
return (props: Omit<P, 'flag'>) => <Comp {...(props as P)} flag={true} />
}If P included optional properties, those stay optional. Be explicit about undefined when using strict null checks.
Q2: Can I preserve ref and displayName for any wrapped component?
A2: Yes. Use React.forwardRef with proper generics for preserving ref, and set displayName on the returned component. For static members, use hoist-non-react-statics. Example patterns were shown above; ensure the forwarded ref type matches the wrapped component's instance or DOM type.
Q3: How do I compose multiple HOCs without losing prop types?
A3: Compose HOCs in a typed manner. For complex typing, create a variadic generic compose utility or apply HOCs from right-to-left using explicit generics. If you get inference loss, annotate the intermediate component's generic. In many practical situations, composing two or three HOCs works well when each HOC is typed using P extends object and returns components typed with Omit for injected props.
Q4: Should I convert HOCs to hooks?
A4: Hooks are often cleaner and easier to type for behavior tied to functional components. HOCs remain useful for cross-cutting concerns applied to many components (especially class components). If you have full control over code, prefer hooks for new code. For existing codebases with many class components, typed HOCs are still valuable.
Q5: How do TypeScript compiler options affect HOC typing?
A5: Options like strictNullChecks force you to explicitly handle undefined values for injected props. esModuleInterop affects import shapes for some packages, which can impact published HOC libraries — see esModuleInterop. isolatedModules restricts some type-only patterns, so read isolatedModules if you use Babel-only transpilation.
Q6: My HOC lost static members. Why and how fix it?
A6: Wrapping a component with a function returns a new object without the original static members. Use hoist-non-react-statics to copy safe statics from the wrapped component to the wrapper. This preserves things like custom static methods, default props, or a WrappedComponent reference for testing.
Q7: How do I type a parameterized HOC (factory) that accepts options?
A7: Use a factory function that returns a generic HOC and preserve literal types for options by making the factory generic. Example above shows how TOption extends string preserves literal types. This helps the wrapped component receive the exact option type.
Q8: What about building HOCs in libraries — how to publish types?
A8: Build with declaration: true so consumers get .d.ts files. Follow best practices around esModuleInterop and ensure your published package type entry is correct. See our guide on generating declaration files for steps and CI recommendations.
Q9: Are there utility types to help with method binding and this in HOCs?
A9: Yes. ThisParameterType<T> extracts this from function types, while OmitThisParameter<T> removes it. These are useful when wrapping components that pass down methods relying on this, or when you want to convert a method type for callbacks. Learn more in our utility deep dives: OmitThisParameter
Q10: What are common migration steps for converting legacy untyped HOCs to typed versions?
A10: Start by adding explicit generic parameters to the HOC and annotate the wrapped component type. Replace any with unknown or a generic P extends object. Add Omit for injected props, forward refs, and run the type checker. Enable strict flags incrementally (e.g., strictNullChecks) and address failures. Consider extracting complex behaviors into hooks where typing is simpler. Use CI to run full tsc checks and generate declaration files if shipping a library.
Further reading that pairs well with this guide: learn about how TypeScript organizes compiler options in tsconfig categories, or dive into decorator patterns if your HOCs are used alongside class decorators in legacy code (Decorators in TypeScript).
If you maintain a large codebase with mixed JS/TS files, see the guide on allowing JavaScript files in a TypeScript project for migration strategies.
Happy typing — write safe, composable HOCs that scale with your app!
