How to Define an Interface with conditional properties in TypeScript

TypeScript is a popular programming language that provides optional static typing, making it easier to catch and prevent errors before runtime. One of TypeScript's most powerful features is its support for interfaces, which define the shape of objects and ensure that they conform to a specific structure. Let's explore how to define an interface with conditional properties in TypeScript. We'll use a specific example to demonstrate this concept.

Suppose we have an object with several properties, including name, value, groupId, groupName, and inherited boolean flag. The name and value properties are always present, but the groupId and groupName properties depend on the inherited flag. If inherited is true, then groupId and groupName must be present and of type string. Otherwise, both properties should be null.

Easy but incorrect

You can define an interface for your object as follows:

interface MyObject {
name: string;
value: string;
groupId: string | null;
groupName: string | null;
inherited: boolean;
}

In this interface, name and value are always present and of type string. The groupId and groupName properties can be either a string or null, depending on whether inherited is true or false. Technically this should work because with this interface, you can create objects that conform to this structure, like so:

const myObject: MyObject = {
name: "myName",
value: "myValue",
groupId: null,
groupName: null,
inherited: false,
};

Or, if inherited is true:

const myInheritedObject: MyObject = {
name: "myName",
value: "myValue",
groupId: "myGroupId",
groupName: "myGroupName",
inherited: true,
};

However this is not correct as this interface allows me to create object like this:

const myInheritedObject: MyObject = {
name: "myName",
value: "myValue",
groupId: null,
groupName: null,
inherited: true,
};

I want to have type of groupId and groupName dependent on the inherited flag value, but given interface is not strict enough to guarantee that objects will be created as expected.

Better approach - union type

To define an interface for this object, we need to use a union type. Here's the code:

interface MyInheritedObject {
name: string;
value: string;
inherited: true;
groupName: string;
groupId: string;
}

interface MyObjectNotInherited {
name: string;
value: string;
inherited: false;
groupName: null;
groupId: null;
}

type MyObjectUnion = MyInheritedObject | MyObjectNotInherited;

In this interface, we have defined two interfaces, MyInheritedObject for when inherited is always true and MyObjectNotInherited for when inherited is false. The groupName and groupId properties are required for MyInheritedObject and are not allowed for MyObjectNotInherited. We have then defined a union type MyObjectUnion that can be either MyInheritedObject or MyObjectNotInherited.

With this interface, we can create objects that conform to this structure, like so:

const myObject: MyObjectUnion = {
name: "myName",
value: "myValue",
inherited: false,
groupName: null,
groupId: null,
};

const myInheritedObject: MyObjectUnion = {
name: "myName",
value: "myValue",
inherited: true,
groupName: "myGroupName",
groupId: "myGroupId",
};

This ensures that groupId and groupName are only present when inherited is true. Trying to create an object below will return a type error:

const myIncorrectObject: MyObjectUnion = {
name: "myName",
value: "myValue",
inherited: false,
groupName: "myGroupName",
groupId: "myGroupId",
};

In conclusion, defining interfaces with conditional properties is an important feature of TypeScript. By using a union type and carefully defining our interfaces, we can ensure that our objects conform to the correct structure and prevent errors from occurring.

© 2022 Tommy Bernaciak. All rights reserved.