TypeScript Type Negation

// 4 comments

Type negation in TypeScript allows you to create types that explicitly exclude certain properties. Usually, we define types that specify what properties an object must have to satisfy that type. With type negation, we want to do the opposite: We specify which properties an object must not have. You can think of this as reserved properties.

Let's consider the following example. We have a generic createItem function that inserts a new item into our NoSQL database (e.g. MongoDB, DynamoDB, etc.). The NoSQL database and its tables do not have a defined column schema, that means the item with all its properties is stored as is. However, in order to retrieve the items, we need to define at least one property as a primary key (e.g. in DynamoDB this is the hash key). This is usually an ID as an integer or UUID, which can be defined either by the database or the application.

function createItem<TItem extends object>(item: TItem): TItem { // Database sets ID const newItem = db.insert(item); return newItem; } // Returns object with id { id: "0d92b425efc9", name: "John", ... } const user = createItem({ name: "John", email: "john@doe.com" });

We can call this function with any JavaScript object and store it in our NoSQL database. But what happens if the object we pass in contains an id property?

// What will happen? //> Will it create a new item? //> Will it overwrite the item if it exists? const user = createItem({ id: "0d92b425efc9", name: "John", email: "john@doe.com" });

What actually happens depends on the database, of course. A new item could be created, or an existing item could be overwritten, or even an error could be thrown if we are not supposed to specify an external ID. Of course, we can simply add a check to the id property and raise an error ourselves to prevent such cases.

function createItem<TItem extends object>(item: TItem): TItem { if('id' in item) throw new Error("Item must not contain an ID"); // Database sets ID const newItem = db.insert(item); return newItem; } // Throws an error const user = createItem({ id: "0d92b425efc9", name: "John", email: "john@doe.com" });

Let's go one step further and use TypeScript generics and types to prevent such cases from happening in the first place. We simply forbid the item to contain an id property.

type ReservedKeys = { id: string; } function createItem<TItem extends object>( item: TItem extends ReservedKeys ? never : TItem ): TItem { if('id' in item) throw new Error("Item must not contain an ID"); // Database sets ID const newItem = db.insert(item); return newItem; }

In this example we define a ReservedKeys type with forbidden keys. These are the properties that should not be allowed for the item. In the function signature, we then use TItem extends ReservedKeys to check if the generic TItem is a subset of ReservedKeys. If it is, we set the element type to the special value never.

Let's go back to our previous example. Now what happens when we specify an object with ID?

// What will happen? //> TypeScript error: Argument of type '{ id: string; name: string; email: string; }' is not assignable to parameter of type 'never' const user = createItem({ id: "0d92b425efc9" name: "John", email: "john@doe.com" });

TypeScript reports an error that the object we passed to the function doesn't match the expected type.

TypeScript
TypeScript Playground

Of course, we should never rely on static type checking alone to avoid such errors. Checking the property id at runtime within the implementation should always be present. The type negation is rather syntactic sugar to catch possible errors already at compile time and to have a function signature that matches the implementation.