Search code examples
typescript

Make a property required based on other property's value


I'm working on a usecase where I need to enforce a set of object keys based on certain conditions meaning i have an object type with some properties and I want to define a utility type that can conditionally enforce the presence of these properties based on the values of other properties

Here is the type definition:

export type User = {
  id: string;
  name: string;
  email?: string;
  phoneNumber?: string;
  contactPreference: 'email' | 'phone';
};

Now i want to create a type ConditionalRequired which makes sure that

  • if contactPreference is email, then email is required
  • if contactPreference is phone, then phone is required

Example:

// happy flow
const user1: ConditionalRequired<User> = {
  id: '1',
  name: 'foo',
  contactPreference: 'email',
  email: '[email protected]', // here email is required
};

// Here it should cause type error because email is missing
const user2: ConditionalRequired<User> = {
  id: '2',
  name: 'foo2',
  contactPreference: 'email',
};

// Here it should cause type error because phone is missing
const user3: ConditionalRequired<User> = {
  id: '3',
  name: 'foo3',
  contactPreference: 'phone',
};

This is my unsuccessful attempt to achieve this:

type ConditionalRequired<T> = T & {
  [K in keyof T]: T[K] extends 'email' ? { email: string } :
                 T[K] extends 'phone' ? { phoneNumber: string } : {};
};


Solution

  • The use case you're describing would be best served by a union type and not a conditional type.

    You want to say that your user objects should have either an "email" contactPreference and a required email property, or a "phone" contactPreference and a required phoneNumber property. That "either-or" relationship is what unions do.

    In your case, we can represent the two possibilities as their own subtypes of User:

    interface EmailPreferredUser extends User {
      contactPreference: 'email',
      email: string;
    }
    
    interface PhonePreferredUser extends User {
      contactPreference: 'phone',
      phoneNumber: string;
    }
    

    And then your desired type is the union of them:

    export type ConditionallyRequiredUser =
      EmailPreferredUser | PhonePreferredUser
    

    Note that I didn't write a ConditionalRequired<T> utility type, since it's not clear that such a thing is useful. If the implementation needs to know about phone and email then it's not general enough to be reusable. Your example only used ConditionalRequired<User>.

    Okay, let's test it:

    const user1: ConditionallyRequiredUser = {
      id: '1',
      name: 'foo',
      contactPreference: 'email',
      email: '[email protected]', 
    }; // okay
    
    const user2: ConditionallyRequiredUser = { // error!
    //    ~~~~~ <-- Property 'email' is missing
      id: '2',
      name: 'foo2',
      contactPreference: 'email',
    };
    
    const user3: ConditionallyRequiredUser = { // error!
    //    ~~~~~
    // Property 'phoneNumber' is missing 
      id: '3',
      name: 'foo3',
      contactPreference: 'phone',
    };
    

    Looks good. It behaves as desired in both the good and bad cases.

    Playground link to code