If you've worked in a large codebase with a large team, you'll know that sometimes working with less-than-ideal 3rd
party library code can be inevitable sometimes. In a TypeScript project, this can often take the form of code that works,
but does not have the proper typings. So, how do you ensure type safety in this case? Enter the Modify type.
(If you would like to follow along with this article's code: TypeScript playground)
The Modify Type
type Modify<Type, Replace> = Omit<Type, keyof Replace> & Replace;
Let's break down what this type does. First, it accepts two arguments: Type and Replace. Type is the thing
that will have modifications done to it. Replace is what modifications will be made to Type.
The Omit<Type, keyof Replace> removes all properties from the Type which are going to be modified. Omit is a
built-in utility type to TypeScript.
Then, the & Replace re-adds the modified types the resulting type from Omit, which results in an object that
has exactly the same type, but with only specific types replaced.
Let's look at an example of modifying a Person type.
Modify Example
type Person = {
name: string;
age: number;
address: string;
};
Suppose that we want to change the address property to allow for an object, which might contain more in-depth
address information such as street address, country code, province/state, etc.
// The modifications that we want to make to the `Person` type
type Replace = { address: string | object };
type A = Omit<Person, keyof Replace>; // A = { name: string; age: number; }
type B = Replace; // B = { address: string | object; }
type Modified = A & B; // Modified = { name: string; age: number; address: string | object; }
// At this point, Modified == Modify<Person, Replace>
const person: Modified = {
name: "test",
age: 30,
address: { street: "123 example street" },
};
Without showing the intermediate types, here is what the same example might look like in a more real-world usage:
type ModifiedPerson = Modify<Person, { address: string | object }>;
const person: ModifiedPerson = {
name: "test",
age: 30,
address: { street: "123 example street" },
};
Using Modify With 3rd Party Code
The main value of Modify is being able to modify any type and change only the things that you need, even if that
type comes from somewhere else (like a library). You can create an interface which wraps over 3rd party types and provides
greater safety as opposed to using any or casting to other types.
Library Example
To show how Modify can be used for working with libraries, let us take a look at a theoretical example of how this
might be used in an application. Suppose that we are using a library to list out some items. But, the types of the
library use any in a few places, and it is not generic, so we cannot add our own types.
interface ThirdPartyItem {
// Name of the item
name: string;
// This data can be anything you want, it's just associated
// with the item, but not used in any way
data: any;
}
interface ThirdPartyListProps {
items: ThirdPartyItem[];
onClick?: (item: ThirdPartyItem) => void;
}
function ThirdPartyList({ items }: ThirdPartyListProps) {
return <div />; // Assume this function actually listed out the items
}
All of the code that comes from the library is prefixed with ThirdParty, to show that we cannot change it. The
ThirdPartyList component accepts an array of items and a click event handler for when the user clicks on an item.
However, we will now see how the any type for the data property can cause some type safety problems.
const fruitDatabase = {
apple: { color: "red" },
banana: { color: "yellow" },
grape: { color: "purple" },
};
// Transform our fruit database into a form that can be consumed by the
// "3rd party" component we are using
const fruits = Object.entries(fruitDatabase).map(([fruitName, info]) => ({
name: fruitName,
data: info,
}));
function App() {
// @ts-expect-error: Type safety works! The property `asdf` is
// non-existent on the fruit information type.
console.log(fruits[0].data.asdf);
return (
// OOPS! No error in `onClick`. We lost our type safety here because the third party
// component which uses `data` as `any`
<ThirdPartyList
onClick={(fruit) => console.log(fruit.data.asdf)}
items={fruits}
/>
);
}
When we access fruits[0], TypeScript can infer the full type and ensure we cannot misuse the object. On the other
hand, when we access the fruit argument in the onClick handler, the type is given by the definition of ThirdPartyItem
which has data typed as any. This allows us to write code that can crash at run-time, which is never what we want.
It would be great if we could just change the relevant props of ThirdPartyList to be generic so that TypeScript
could infer all of the types and prevent us from making a mistake. We can do exactly that with Modify!
// Create a generic version of `ThirdPartyItem` that removes the `any` type
type Item<Data> = Modify<ThirdPartyItem, { data: Data }>;
// Update the relevant props which use the item type
type ListProps<Data> = Modify<
ThirdPartyListProps,
{
items: Item<Data>[];
onClick?: (item: Item<Data>) => void;
}
>;
// Wrapper component to make a generic version of ThirdPartyList
function List<T>(props: ListProps<T>) {
return <ThirdPartyList {...props} />;
}
First, we create a generic version of the library's item type which has the generic Data type instead of any. This
allows TypeScript to infer the type based on what we pass in to the component.
Next, we modify the component's props that used the previous item type and allow them to be generic also. In this particular case, we actually redefined the entire interface. For many components though, most props will not need to be modified.
Lastly, we create a generic wrapper component which allows us to use ThirdPartyList in a way that TypeScript can infer
all of the types. Now, returning to our application code from before, we can just update it to use the new component:
function App() {
// @ts-expect-error: Type safety still works here to prevent using `asdf`
console.log(fruits[0].data.asdf);
return (
// @ts-expect-error: Type safety works now!
// The type of `data` is inferred from `items`
<List onClick={(fruit) => console.log(fruit.data.asdf)} items={fruits} />
);
}
The type of data is inferred from the data passed in to the items property, and TypeScript prevents us from accessing
a property which does not exist.
Caveats
Modify is useful for overriding external types from libraries and creating safe wrappers around them, but it should probably not be the first thing that you try.
If it is available, it is better to use a generic interface from the library that allows you to accomplish the same thing. Using
Modify means maintaining an additional interface every time that the underlying library type updates too. Even more dangerously,
since we are replacing types, some type changes can actually be overridden accidentally if you do not pay attention
when updating dependencies.
There are a number of benefits to using Modify though:
- Prevents the need to cast with
aseach time you need to access a property of typeanyorunknown - Can be easily removed or changed in a single place if the library adds a generic interface later
Conclusion
The Modify type can be extremely useful for working with external library types and code that do not quite have
the correct types. Instead of completely abandoning type safety, we can use Modify to build safe wrappers around
just the pieces of code that are problematic.