React 101: A Practical Guide to Using React with TypeScript
- 文章發表於
This article serves as a refresher on key concepts when writing React with TypeScript. Whether you're new to TypeScript or have some experience, this guide aims to be helpful for developers at all levels.
Getting Started
In React, we primarily use JSX (JavaScript XML) to build user interfaces. It's a syntax extension provided by React, and the JSX you write gets transpiled into React.createElement
, which React then uses to construct the React Element Tree.
const element = <div className="container">Hello TypeScript!</div>;Behind the scenes, this compiles to:const element = React.createElement("div", { className: "container" }, "Hello TypeScript!");
To ensure TypeScript correctly compiles JSX syntax and supports React 17+'s new JSX Transform, we need to:
- Set
"jsx": "react-jsx"
intsconfig.json
- Install
@types/react
and@types/react-dom
, which provide TypeScript type definitions for React and ReactDOM
Without this configuration, TypeScript might fail to parse JSX correctly or use incompatible compilation methods, leading to compile errors or runtime issues.
// tsconfig.json{"compilerOptions": {"jsx": "react-jsx",...}}// terminalnpm install --save-dev @types/react @types/react-dom
Components
We can create a .tsx
file and start writing our first component. You might wonder why <div>
or the props you pass don't throw errors—like id
or onChange
—and even offer autocomplete functionality.
export const MyComponent = () => {return (<div// How do I figure out what type id expects?id="My Components"// How do I figure out what type onChange expects?onChange={() => {}}/>);};
These are all predefined in @types/react
. When you hover over <div>
and use ctrl
+ right-click
, you'll see that React has pre-defined HTML tags in index.d.ts
.
// index.d.tsdeclare global {namespace JSX {interface IntrinsicElements {div: React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>;// ... all HTML elements}}}
Props
A key feature of React is composing multiple components into pages, where props
play a crucial role in passing information between parent and child components. In TypeScript, we define prop types using interface
or type
; otherwise, TypeScript will throw errors during compilation.
// ❌export const Button = (props: unknown) => {return <button className={props.className}></button>;// ^^^^^^^^^^^^^^^ TypeScript error!};// ✅interface Props {className: string;}// ortype Props = {className: string;};export const Button = (props: Props) => {return <button className={props.className}></button>;};
interface & type
Both interface
and type
can be used to define object types, but they have some differences.
interface
You can define the same interface
multiple times within the same file, and TypeScript will automatically merge them. Interfaces can also be extended, similar to class inheritance.
// samefile.tsinterface User {name: string;}interface User {age: number;}// After automatic merging, it's equivalent to:// interface User {// name: string;// age: number;// }
type
type
is typically used for union types, intersection types, and more complex type operations.
type Status = 'pending' | 'approved' | 'rejected';type ApiResponse<T> =| { status: 'loading' }| { status: 'success'; data: T }| { status: 'error'; error: string };
React.ReactNode
In real-world development, we often need to pass components down via children
. In such cases, we must define the type for this prop. However, the passed value isn't always a React element; it could be a string, null
, or other types. Here, React.ReactNode
is incredibly useful as a flexible type, offering more versatility than React.ReactElement
.
interface Props {children: React.ReactNode;}const Comp = ({ children }: Props) => {return <div>{children}</div>;};
If you look in @types/react
index.d.ts, you'll find that ReactNode is a union of all these types:
type ReactNode =| ReactElement| string| number| Iterable<ReactNode>| ReactPortal| boolean| null| undefined
Best Practice Decision Tree
// Use this decision tree:interface Props {// ✅ General content that React can renderchildren: React.ReactNode;// ✅ Specifically need element objects (rare)icon: React.ReactElement;// ✅ Function return types (JSX expressions)render: () => JSX.Element;}
Event handlers are also common props. For example, the type for an onClick
event would be React.MouseEventHandler<HTMLButtonElement>
.
interface ButtonProps {// ...onClick: React.MouseEventHandler<HTMLButtonElement>;}export const Button = ({ children, className, onClick }: ButtonProps) => {return (<button onClick={onClick} className={className}>{children}</button>);};
You might think that defining every basic HTML prop passed down from a parent component would slow development. React provides ComponentProps<T>
to address this!
import { ComponentProps } from "react";export const Button = ({className,...rest}: ComponentProps<"button">) => {return (<button {...rest} className={className}></button>);};<ButtononClick={() => {}}type="submit"disabledaria-label="Submit form"data-testid="submit-btn"// ... other button props/>
However, be cautious of potential conflicts. For instance, if we want to pass a custom onChange
with type onChange: (value: string) => void
, but React's defined onChange
has a different type, this causes a type conflict.
To resolve this, use TypeScript's Omit
to exclude React's onChange type, then combine it with your custom onChange
.
❌ This creates a type conflict:type InputProps = ComponentProps<"input"> & {onChange: (value: string) => void};✅type InputProps = Omit<ComponentProps<"input">, "onChange"> & {onChange: (value: string) => void;};export const Input = (props: InputProps) => {return (<input{...props}onChange={(e) => {props.onChange(e.target.value); // Now we pass just the string}}/>);};
React Hooks
useState
Type Annotation
When using useState
with an empty array as the initial value, React might not be able to infer the variable's type as it does with primitive values. In such cases, type annotation becomes crucial.
❌ TypeScript infers: never[]const [users, setUsers] = useState([]);✅type User = {id: number;address: string;};const [users, setUsers] = useState<User[]>([]);
Handling Undefined State
A common TypeScript issue in React development when using fetch
to retrieve backend data is dealing with undefined state during loading. The typical flow when fetching data from an API is:
- Component initializes → state is empty
- API request sent → data loading
- Response received → state updated and re-render triggered
- During steps 1 and 2, our state effectively has "no data yet," but without proper type definitions, TypeScript will throw compile-time errors.
Therefore, we need to explicitly include undefined
or null
in our type definitions to inform TypeScript that this state might be empty at times:
type Data = {id: number;name: string;}const Comp = () => {const [user, setUser] = useState<User | undefined>();useEffect(() => {fetchUser().then(setUser);}, []);// ✅ Best: Early return with type narrowingif (!user) {return <div>Loading...</div>;}// TypeScript now knows user is definitely User (not undefined)return <div>Welcome, {user.name}!</div>;};
Type Checking
When updating state, without proper type definitions, we might miss potential typos. TypeScript performs "excess property checking" when you explicitly specify return types. Without type annotations, TypeScript allows extra properties to exist.
type User = {id: stringname: string;isPro: boolean;}// ❌ Typo! Should be "isPro"setState((currentState) => ({...currentState,isPor: true,}));// ✅ Now TypeScript catches the typo!setState((currentState): User => ({...currentState,isPro: true,}));
useCallback & useMemo
useCallback
primarily avoids re-rendering expensive functions by memoizing them to cache the function, only recreating it when dependencies change. Since it receives a function as a parameter, you need to define both input and output types.
// ❌ Wrong! string is not a function typeconst onClick = useCallback<string>((buttonName) => {console.log(buttonName);},[]);// ✅ Explicit function typeconst onClick = useCallback<(buttonName: string) => void>((buttonName) => {console.log(buttonName);},[]);
useMemo
, on the other hand, avoids repeating expensive calculations, so you only need to define the return type.
// ❌ Wrong! Returns function typeconst autoGeneratedIds = useMemo<() => string[]>(() => {return Array.from({ length: 100 }, () =>Math.random().toString(36).substr(2, 9));}, []);// ✅ Explicit return typeconst autoGeneratedIds = useMemo<string[]>(() => {return Array.from({ length: 100 }, () =>Math.random().toString(36).substr(2, 9));}, []);
Key Differences
useMemo<T>
whereT
= return value typeuseCallback<T>
whereT
= function type itself
useRef
Basic Usage
useRef
requires explicit definition of the type you're putting in; otherwise, TypeScript will throw compile-time errors.
// ❌ TypeScript infers: useRef<undefined>const id = useRef();// id.current can only ever be undefined!
// ✅ Can hold string | undefinedconst id = useRef<string>();const timer = useRef<NodeJS.Timeout>(); // For timer IDsconst count = useRef<number>(0); // With initial value
DOM References
Most of the time, useRef
is used to reference DOM elements. However, since React constructs the React Element Tree at browser runtime, useRef
can be undefined initially. In such cases, we need to predefine null
, similar to how we handle asynchronous operations with useState
.
// ❌ Missing initial valueconst ref = useRef<HTMLDivElement>();return <div ref={ref} />; // Type error!// ✅ Explicitly pass nullconst ref = useRef<HTMLDivElement>(null);return <div ref={ref} />;
Read-Only Behavior
Another aspect of useRef
that often trips developers up with TypeScript type errors is its three different overload versions, each with specific use cases and behavioral characteristics:
First: Mutable Reference (Non-null Initial Value)
// ✅ Works correctly! Returns MutableRefObject<string>const ref1 = useRef<string>("initial");ref1.current = "Hello";
When we provide a non-null initial value to useRef, TypeScript recognizes it as a mutable reference object. This form is mainly used for storing mutable data, such as counters, timer IDs, or other values that need to persist between component re-renders.
Second: Read-Only Reference (Null Initial Value)
// ❌ Error! Returns RefObject<string> (read-only)const ref2 = useRef<string>(null);ref2.current = "Hello";
This is the most common and confusing usage. When the initial value is null
, TypeScript assumes this ref is for DOM elements and returns a read-only RefObject
. The logic behind this design is that DOM references should be managed by React itself, and developers shouldn't directly modify the current property.
Third: Possibly Undefined Mutable Reference
// ✅ Works correctly! Returns MutableRefObject<string | undefined>const ref3 = useRef<string>();ref3.current = "Hello";
When we don't provide an initial value, TypeScript treats it as a mutable reference but includes undefined in the type. This form is suitable for scenarios where there's no initial value but one will be assigned later, such as with setTimeout
.
useReducer
useReducer
is primarily used for more complex state management. When using it, you'll need to define more types for the state. useReducer
takes two parameters: reducer
and initialState
, and returns state
and dispatch
. Let's use a simple counter example to make this clearer.
type State = {count: number;};type AddAction = {type: "add";add: number;};type SubtractAction = {type: "subtract";subtract: number;};type Action = AddAction | SubtractAction;const reducer = (state: State, action: Action) => {switch (action.type) {case "add":return { count: state.count + action.add };case "subtract":return { count: state.count - action.subtract };default:throw new Error();}};// In your React Compconst [state, dispatch] = useReducer(reducer, { count: 0 });
More Type Concepts
Discriminated Unions (Tagged Union)
As mentioned earlier, union types combine multiple types using the |
operator. However, when types overlap, TypeScript needs a way to determine which specific type a value belongs to. This is where union types with a common discriminant property come into play.
type PlaygroundProps =| { useStackblitz: true; stackblitzId: string }| { useStackblitz?: false; codeSandboxId: string };function openPlayground(props: PlaygroundProps) {if (props.useStackblitz) {// TS automatically infers props as { useStackblitz: true; stackblitzId: string }console.log("Opening Stackblitz:", props.stackblitzId);} else {// TS automatically infers props as { useStackblitz?: false; codeSandboxId: string }console.log("Opening CodeSandbox:", props.codeSandboxId);}}
This is particularly useful when handling API responses, as ApiResponse
typically has states like error
, success
, or loading
. Discriminated unions are extremely helpful here, and we'll revisit this concept later.
AllOrNothing Pattern
When writing reusable React components, one of the most common design patterns is choosing between controlled and uncontrolled components. The main difference lies in whether state is passed down from parent components to control the child component's HTML state.
type InputProps = (| { value: string; onChange: ChangeEventHandler }| { value?: undefined; onChange?: undefined }) & { label: string };
However, this approach has type safety issues. If you pass {value: "good", label: "I'm label"}
, TypeScript won't catch the error because onChange?
is optional. We'll discuss more effective solutions in the generics section later.
Type Inference
In daily development, we often need to maintain consistency between runtime values and the TypeScript type system. Otherwise, we face the "code updated but types not updated" problem. This is where advanced type inference comes in handy to ensure a single source of truth.
const buttonVariants = {primary: { className: "btn-primary", color: "white" },secondary: { className: "btn-secondary", color: "black" },ghost: { className: "btn-danger", color: "white" }} satisfies Record<string, ComponentProps<"button">>;type ButtonProps = {variant: keyof typeof buttonVariants;children: ReactNode;};
This is especially useful in design systems. For example, button components often have multiple variants like primary
, secondary
, and ghost
. When business requirements call for adding a new variant, using the above approach ensures that the Variant
type automatically updates along with VARIANT_CLASSES
, maintaining a single source of truth.
Before satisfies
was introduced, we only had two ways to make an object conform to a type:
// Type annotation - ensures config matches Config, but loses literal precision (config.mode gets inferred as string instead of "dark").const config: Config = { mode: "dark" };// Type assertion - preserves "dark" literal, but TypeScript won't validate if the object actually conforms to Config.const config = { mode: "dark" } as Config;// Checks if object conforms to Config (safe) while preserving original literal precision (exact)const config = { mode: "dark" } satisfies Config;
Generics
Generics are common syntax in strongly typed languages. In TypeScript, generics primarily enable type reuse while maintaining type safety. Without generics, you'd have to choose between type safety (using concrete types) and code reusability (any
).
// ❌ Type safe but not reusablefunction getFirstString(arr: string[]): string | undefined {return arr[0];}function getFirstNumber(arr: number[]): number | undefined {return arr[0];}// ❌ Reusable but not type safefunction getFirst(arr: any[]): any {return arr[0];}// ✅ Both type safe AND reusablefunction getFirst<T>(arr: T[]): T | undefined {return arr[0];}
Syntax
We typically use T
to represent generics, where T
can be any valid TypeScript type. Take the identity
function as an example - it's a common function in functional programming that simply returns the input value.
function identity<T>(value: T): T {return value;}identity<number>(1) // 1, but usually we let TS infer: identity([1, 2, 3])
The above shows a single generic example. For multiple generics, you can use this syntax:
interface KeyValuePair<K, V> {key: K;value: V;}function createPair<K, V>(key: K, value: V): KeyValuePair<K, V> {return { key, value };}const stringNumberPair = createPair("age", 25);// Type: KeyValuePair<string, number>
Type Helper
Earlier we discussed union types, but sometimes you need to loosen restrictions beyond predefined union types to allow custom values. For example, button variants might include multiple options (primary
, ghost
, etc.):
type ButtonVariants = 'primary' | 'ghost'
The above approach restricts variants to only primary
and ghost
. To loosen this restriction, you might write:
type ButtonVariants = 'primary' | 'ghost' | string
But this breaks TypeScript's autocomplete functionality because ButtonVariants
becomes any string. When a union type includes the broad string
type, TypeScript's IntelliSense considers all strings valid and won't suggest specific options like primary
or ghost
.
type ButtonVariants = 'primary' | 'ghost' | (string & {});
This clever syntax tells TypeScript that we want to preserve predefined option suggestions while allowing custom values. It leverages TypeScript's type system特性: string & {}
is logically equivalent to string
since any string satisfies the empty object condition.
However, TypeScript's autocomplete mechanism treats string
and string & {}
differently. With string & {}
, the editor still prioritizes suggesting literal types from the union (primary
and ghost
) while maintaining flexibility to accept any string value.
This pattern can be used frequently, but we don't want to manually add string & {}
every time. This is where we can create a reusable Type Helper using generics:
type LooseAutocomplete<T> = T | (string & {});type LooseIcon = LooseAutocomplete<"home" | "settings" | "about">;type LooseButtonVariant = LooseAutocomplete<"primary" | "ghost">;
AllOrNothing
Earlier we mentioned that when writing reusable React components, the most common design pattern is choosing between controlled and uncontrolled components. We initially used union types for this, but that approach had limitations where TypeScript couldn't effectively detect potential errors.
This is where we can create a Type Helper using generics:
type AllOrNothing<T extends Record<string, any>> = T | ToUndefinedObject<T>;type ToUndefinedObject<T extends Record<string, any>> = Partial<Record<keyof T, undefined>>;// Usage: Controlled vs Uncontrolled componentstype InputProps = AllOrNothing<{value: string;onChange: (value: string) => void;}> & {label: string;};// ✅ Fully controlled<Input label="Name" value={name} onChange={setName} />// ✅ Fully uncontrolled<Input label="Name" />// ❌ Partially controlled (TypeScript error)<Input label="Name" value={name} />
This AllOrNothing Helper solves a critical problem: ensuring developers either use fully controlled or fully uncontrolled patterns with React components, preventing "semi-controlled" state errors. ToUndefinedObject<T>
converts the input type T
into an object type where all properties are optional with undefined
values. When combined with the original type T
in a union, it creates an "all or nothing" constraint.
Constraint Patterns
Handling API response data is a common task in daily development. We touched on similar concepts earlier with union types, but that approach had issues where error types became any
, losing type safety. We couldn't know the specific structure of error objects or get proper hints, which caused problems when handling different error types.
// Traditional approachtype ApiResponse<T> =| { success: true; data: T }| { success: false; error: any };// Usage problemsfunction handleResponse<T>(response: ApiResponse<T>) {// ✅ Type hints availableif (response.success) {// TypeScript knows this is the success caseconsole.log(response.data);} else {// ❌ error type is any, losing type safetyconsole.log(response.error);}}
The main benefit of the following approach is that TypeScript can automatically determine the return structure based on the input type. When the backend returns an error, the type system knows it's a failure response; otherwise, it's a success. This enables proper hints and type checking in the editor.
type ApiResponse<T extends object> = T extends { error: any }? { success: false; error: T['error'] }: { success: true; data: T };type UserResponse = ApiResponse<{ name: string; address: string }>;// Result: { success: true; data: { name: string; age: string } }type ErrorResponse = ApiResponse<{ error: string }>;// Result: { success: false; error: string }
React with Generics
Generic Hook
With generics, we can implement universal React Hooks. Take useLocalStorage
as an example - it handles getting and updating values. The type signature can be defined as:
// Type Signatureconst useLocalStorage = <T>(key: string): {value: T | null;setValue: (value: T) => void;}// Usageconst { value: user, setValue: setUser } = useLocalStorage<User>("user");
This design offers both convenience and type safety. First, useLocalStorage
becomes a completely reusable Hook that can store any type of data using the same implementation.
More importantly, it provides type safety guarantees. When you specify useLocalStorage<User>("user")
, TypeScript knows the user variable has type User
| null, and the setUser
function only accepts parameters of type User
. This prevents type errors during usage, such as accidentally passing incorrectly formatted data to setUser
.
export const useLocalStorage = <T>(key: string): {value: T | null;setValue: (value: T) => void;clearValue: () => void;} => {const [value, setValue] = useState<T | null>(null);useEffect(() => {const stored = localStorage.getItem(key);if (stored) {setValue(JSON.parse(stored));}}, [key]);const handleSetValue = (newValue: T) => {setValue(newValue);localStorage.setItem(key, JSON.stringify(newValue));};const clearValue = () => {setValue(null);localStorage.removeItem(key);};return { value, setValue: handleSetValue };};
Generic Function Component
renderSomething
is a common rendering pattern in React. Let's use the Table
component as an example:
interface TableProps<T> {data: T[];renderRow: (item: T, index: number) => ReactNode;keyExtractor: (item: T) => string | number;}export const Table = <T,>({ data, renderRow, keyExtractor }: TableProps<T>) => {return (<table><tbody>{data.map((item, index) => (<tr key={keyExtractor(item)}>{renderRow(item, index)}</tr>))}</tbody></table>);};
When using the Table
component, TypeScript automatically infers T
from the data
array type, ensuring that the first parameter of the renderRow
function and the parameter of the keyExtractor
function both have the correct types.
Generic Type Guards
When fetching data from APIs or handling user input, the data type is often unknown
or any
. A safer approach is to validate the structure and type before using the data.
The generic T
makes this function applicable to any type, while the typeGuard
parameter accepts a type guard function responsible for validating whether individual elements match the expected type. The return type value is T[]
tells TypeScript that if this function returns true
, then value
can be treated as type T[]
.
function isArrayOfType<T>(value: unknown,typeGuard: (item: unknown) => item is T): value is T[] {return Array.isArray(value) && value.every(typeGuard);}// Usageconst isUser = (obj: unknown): obj is User => {return typeof obj === 'object' && obj !== null && 'name' in obj;};if (isArrayOfType(data, isUser)) {// data is now typed as User[]data.forEach(user => console.log(user.name));}
Advanced Concepts
Tuple Return Types
When creating custom Hooks to mimic React's built-in Hooks (like useState), developers often encounter a fundamental TypeScript challenge: type widening. This is one of the most common challenges in React TypeScript development.
Understanding Type Widening
Type widening is TypeScript's default behavior that makes types more generic to "help" developers. However, this often backfires in Hook development:
// We want: a tuple [string, Dispatch<SetStateAction<string>>]// But get: a union array (string | Dispatch<SetStateAction<string>>)[]export const useId = (defaultId: string) => {const [id, setId] = useState(defaultId);// id: string// setId: Dispatch<SetStateAction<string>>return [id, setId];// TypeScript thinks: "This is a mutable array that might change"// Infers as: (string | Dispatch<SetStateAction<string>>)[]};const [id, setId] = useId("1");// Problem: both id and setId have type: string | Dispatch<SetStateAction<string>>// This means you can't safely use either!// These would all be errors:id.toUpperCase(); // ❌ Error: Property 'toUpperCase' does not exist on type 'Dispatch<SetStateAction<string>>'setId("new-id"); // ❌ Error: This expression is not callable
The main reason is that TypeScript's type inference system is designed conservatively. When it sees array literals, it assumes arrays are mutable and flexible, erring on the side of being too generic rather than too specific. However, for Hook return values, we want immutable, ordered, typed tuples rather than flexible arrays.
The most direct solution is to explicitly tell TypeScript what we want:
export const useId = (defaultId: string): [string, React.Dispatch<React.SetStateAction<string>>] => {const [id, setId] = useState(defaultId);return [id, setId];};// Now we get correct types when usingconst [id, setId] = useId("test");// id: string (not string | Dispatch)// setId: Dispatch<SetStateAction<string>> (not string | Dispatch)
The as const
assertion is TypeScript's way of expressing "maintain exact structure":
export const useId = (defaultId: string) => {const [id, setId] = useState(defaultId);return [id, setId] as const;};
The as const
assertion fundamentally changes how TypeScript infers types. It tells TypeScript to treat the value as an immutable literal rather than a mutable type. For Hooks, this perfectly solves the type widening problem, giving us precise tuple types while keeping the code concise.
React Hook - useContext
React's Context API is powerful for sharing state across components, but it presents significant challenges in TypeScript. Traditional patterns often lead to runtime errors and poor developer experience:
// ❌ Traditional problematic patternconst UserContext = React.createContext(null);const useUser = () => {const user = useContext(UserContext);// Problem 1: user could be null at runtime// Problem 2: no type information about what user should containif (!user) {throw new Error("useUser must be used within UserProvider");}return user; // TypeScript doesn't know what this is};
This approach has several key flaws: easy to forget null
checks leading to runtime errors, poor developer experience with no type information or autocomplete, requiring extensive boilerplate code to add new contexts, and difficulty with mocking and testing.
Generic Solution Deep Dive
Our goal is to create a utility function that eliminates null checks through design, maintains complete type information throughout the flow, provides good developer experience, and is reusable for any data type:
const createRequiredContext = <T,>() => {const context = React.createContext<T | null>(null);const useContext = (): T => {const contextValue = React.useContext(context);if (contextValue === null) {throw new Error("Context value is null");}return contextValue;};return [useContext, context.Provider] as const;};
Let's trace how type information flows. When you call createRequiredContext<User>()
, TypeScript replaces generic T
with the User
type, creating React.createContext<User | null>(null)
. The hook function's type becomes () => User
, and the Provider's type becomes React.Provider<User | null>
.
Function Overloads
Function Overloads allow us to have multiple type signatures for the same function. TypeScript provides different return types based on the parameters passed.
// ❌ Without overloads - too broadfunction getValue(defaultValue?: string): string | undefined {return defaultValue || (Math.random() > 0.5 ? "random" : undefined);}const definiteValue = getValue("hello"); // Type: string | undefined (but we know it's string!)const maybeValue = getValue(); // Type: string | undefined (correct)// ❌ Unnecessary check// We have to do unnecessary null checks:if (definiteValue) {console.log(definiteValue.toUpperCase());}
Using Function overloads solves the problems encountered above:
// ✅ With overloads - precise typesfunction getValue(defaultValue: string): string; // Overload 1function getValue(): string | undefined; // Overload 2function getValue(defaultValue?: string): string | undefined { // Implementationreturn defaultValue || (Math.random() > 0.5 ? "random" : undefined);}const definiteValue = getValue("hello"); // Type: string ✅const maybeValue = getValue(); // Type: string | undefined ✅
This pattern is also common in functional programming, like with curry
functions. We typically don't know how many parameters users will pass, but we want to provide different return types based on parameter count:
// Curry function with overloadsfunction curry<A, B, C>(fn: (a: A, b: B) => C): (a: A) => (b: B) => C;function curry<A, B, C>(fn: (a: A, b: B) => C, a: A): (b: B) => C;function curry<A, B, C>(fn: (a: A, b: B) => C, a: A, b: B): C;function curry<A, B, C>(fn: (a: A, b: B) => C, a?: A, b?: B): any {if (arguments.length === 1) return (a: A) => curry(fn, a);if (arguments.length === 2) return (b: B) => fn(a!, b);return fn(a!, b!);}// Usage with precise typesconst add = (x: number, y: number) => x + y;const curriedAdd = curry(add); // Type: (a: number) => (b: number) => numberconst addFive = curry(add, 5); // Type: (b: number) => numberconst result = curry(add, 5, 3); // Type: number
The main advantage of Function Overloads is providing more precise type inference, helping developers avoid unnecessary type checks and type assertions. Besides improving readability, it makes TypeScript's hints more accurate and reduces potential runtime errors. Particularly when designing libraries or utility functions, Function Overloads provide better developer experience, allowing users to get the most appropriate return types based on different parameter combinations.
More Deep Dive
Global Namespace and Declaration Merging
Declaration Merging is TypeScript's way of allowing multiple declarations to contribute to the same entity. This feature is particularly useful for extending existing type definitions and integrating third-party libraries:
// These declarations merge together:interface User {name: string;}interface User {age: number;}// Result: User has both name and ageconst user: User = { name: "John", age: 30 }; // ✅
The core concept of Declaration Merging is that TypeScript automatically combines interface definitions with the same name into a more complete type. This isn't overriding or replacing, but an additive process. When you define interfaces with the same name in different files or different code blocks, TypeScript intelligently combines all properties.
Global Namespace Augmentation
Global namespace augmentation allows us to extend React's built-in types, adding custom interfaces and properties:
// Extend global React namespacedeclare global {namespace React {// Add custom interfaceinterface MyCustomHook<T> {data: T;loading: boolean;error?: string;}// Extend existing interfaceinterface HTMLAttributes<T> {'data-testid'?: string;'data-analytics'?: string;}// Add custom component typesinterface CustomComponents {'design-button': React.DetailedHTMLPropsReact.ButtonHTMLAttributes<HTMLButtonElement> & {variant: 'primary' | 'secondary';},HTMLButtonElement>;}}}
Through this approach, we can add consistent type definitions for the entire project, ensuring all developers enjoy the same type safety and intelligent hints. Particularly in large projects, this global augmentation ensures team members use unified API and property naming.
JSX Namespace Extension
JSX namespace extension allows us to add type definitions for custom elements and Web Components:
declare global {namespace JSX {interface IntrinsicElements {// Web Components'my-custom-element': {customProp: string;onCustomEvent?: (event: CustomEvent) => void;};// Third-party library elements'chart-component': {data: number[];type: 'line' | 'bar' | 'pie';};}}}// Now these work normally with full type safety:<my-custom-element customProp="value" onCustomEvent={handler} /><chart-component data={[1, 2, 3]} type="line" />
This extension is particularly suitable for scenarios using Web Components or integrating third-party UI libraries. Through type definitions, developers can enjoy the same development experience with these custom elements as with native HTML elements.
Summary
I hope this article helps everyone master the combination of React and TypeScript more quickly!