Performing Surgery on Types with Modify

By Cam McHenry on

SummaryThe Modify type can be used to replace properties in types, which can be useful when working with libraries that have non-generic types.


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 type any or unknown
  • 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.