DEV Community

Chris Cook
Chris Cook

Posted on

Type Negation: How to Forbid Certain Properties

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" });
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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" });
Enter fullscreen mode Exit fullscreen mode

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

TypeScript Playground

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.


I hope you found this post helpful. If you have any questions or comments, feel free to leave them below. If you'd like to connect with me, you can find me on LinkedIn or GitHub. Thanks for reading!

Top comments (4)

Collapse
 
lexlohr profile image
Alex Lohr • Edited

That works for a single key, but you need to use Partial<ReservedKeys> to support multiple reserved keys. In addition, you can even use the types to create a helpful error message:

const ERROR = Symbol("Error");

const reservedKeys = ["id", "class"] as const;
type ReservedKeys = { 
    [Key in typeof reservedKeys[number]]?: unknown
}

function createItem<TItem extends object>(
  item: TItem extends ReservedKeys
    ? TItem & { [ERROR]: `disallowed key: ${keyof ReservedKeys & keyof TItem}` }
    : TItem
): TItem {
  if(reservedKeys.some((key) => key in item)) throw new Error("Item must not contain an ID");
  // Database sets ID
  const newItem = db.insert(item);

  return newItem;
}
Enter fullscreen mode Exit fullscreen mode

This will give you a helpful Error message whenever you use one or more of the reserved keys:

createItem({ id: "error" });
Enter fullscreen mode Exit fullscreen mode

Error Argument of type '{ id: string; }' is not assignable to parameter of type '{ id: string; } & { [ERROR]: "disallowed key: id"; }'.
Property '[ERROR]' is missing in type '{ id: string; }' but required in type '{ [ERROR]: "disallowed key: id"; }'.

You can have a look at this example in the TS Playground.

Collapse
 
zirkelc profile image
Chris Cook

That's a very good point, thank you!

It would be helpful if TS were to support these static type error message natively, for example with a generic never<error message> type or some // @ts-throw-error comment.

Collapse
 
lexlohr profile image
Alex Lohr

Yes, that would be really helpful. A comment won't work, though, because the error is relative to other types.

Collapse
 
niklampe profile image
Nik

Nice, just what I needed