Type-safety by augmentation in TypeScript

Nov 26, 2024
snippet
typescript

Leverage TypeScript's module augmentation feature to create type-safe and reusable components in shared libraries while maintaining flexibility for client-specific customizations.

Person wearing augmented reality glasses.

Recently, I was tasked with moving a simple Track component I created to a shared library. The component’s purpose is straightforward: whenever it is rendered inside a React tree, an action is tracked. I designed the component to auto-complete on the available tracking actions whenever I needed to use it:

The Theme component changing its border color
Track.tsx
import { useEffect } from 'react';
import { track, trackingConfig } from '@/tracking';
interface Props {
action: keyof typeof trackingConfig;
data?: unknown;
}
export function Track({action, data}: Props): JSX.Element {
useEffect(() => {
track(action, data);
}, []);
return <></>;
}

Moving this component seemed trivial until I realized that I had two options:

  • Change the component’s typing to be completely agnostic about the actions that can be tracked (by using action: string).
  • Move the information about the possible actions to be tracked to the library itself, which may cause issues if the library is used by codebases with different tracking actions.

Neither option was ideal.

TypeScript’s Secret Weapon: Module Augmentation

TypeScript interfaces are “open” and allow for extensions. I wondered if I could use this feature to solve my problem: move the Track component to a shared library with a less strict typing, but make it more strict when used inside a concrete codebase.

It turns out this is possible through module augmentation:

Module augmentation, also known as interface merging, enables you to add new properties, methods, or types to an existing module or interface. When you augment a module, TypeScript merges the augmented definitions with the original ones during compilation.

Applying Module Augmentation

To make it work, I first relaxed the typing of my component and exported an interface that serves as an extension point to restrict the typing from the client:

lib/Track.tsx
import { useEffect } from 'react';
import { track } from '@/tracking';
interface BaseProps {
action: string;
data?: unknown;
}
export interface TrackPropsOverride {}
export type Overwrite<T, U> = Pick<T, Exclude<keyfo T, keyof U>> & U;
export type Props = Overwrite<BaseProps, TrackPropsOverride>;
export function Track({action, data}: Props): JSX.Element {
useEffect(() => {
track(action, data);
}, []);
return <></>;
}

With that in place, all I need to do is create a special types file (see typeRoots ) in my concrete codebase that takes care of restricting the typings of the Track component:

import "my-shared-lib/Track";
import { trackingConfig } from "@/tracking";
declare module "my-shared-lib/Track" {
export interface TrackPropsOverride {
action: keyof typeof trackingConfig;
}
}

By leveraging module augmentation, I can now move the Track component to a shared library while still maintaining type safety and auto-completion for the specific tracking actions in each codebase that uses the library.

Conclusion

TypeScript’s module augmentation feature provides a powerful way to extend and customize the typing of external modules and components. By relaxing the typing in the shared library and providing an extension point through an exported interface, we can achieve type safety and auto-completion tailored to each codebase’s specific needs.

This approach allows for flexibility and reusability of shared components while still maintaining a strong type system. Module augmentation is a valuable tool in a TypeScript developer’s toolkit for creating robust and type-safe code across multiple projects.