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
as
each time you need to access a property of typeany
orunknown
- 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.