Search code examples
typescripttypescript-genericsconditional-types

Typescript writing RequiredProps generic using conditional types


Problem

I'm trying to define a generic RequiredProps<T, K> so that I can require a key K on an interface T to be non-nullable.

For example:

interface User {
  id: string;
  phoneNumber?: string;
}
type UserWithPhoneNumber = RequiredProps<User, 'phoneNumber'>

Should yield a type UserWithPhoneNumber equivalent to { id: string; phoneNumber: string }

Solutions

With some trial and error I got something working:

type RequiredProps<T, K extends keyof T> = {
  [P in Exclude<keyof T, K>]: T[P];
} & {
  [P in K]-?: T[P];
};

This results in an equivalent type { name: string } & { phoneNumber: string }, however for readability I would like it to be exactly { id: string; phoneNumber: string }.

Looking at the other types in lib.es5.d.ts I thought I could fix it using conditional types, however the following does not work:

type RequiredProps<T, K extends keyof T> = {
  [P in keyof T]: P extends K ? NonNullable<T[P]> : T[P];
};

Why doesn't this work, and how can it be fixed?

Here's a CodeSandbox to tinker with: https://codesandbox.io/s/serene-meadow-j20w1?file=/src/index.ts


Solution

  • To convert an intersection of object types into a single type, you can use an identity mapping over its properties:

    type Id<T> = { [K in keyof T]: T[K] };
    

    You can see how it behaves:

    type Test = Id<{ a: string } & { b: number } & { c: boolean }>;
    /* type Test = {
        a: string;
        b: number;
        c: boolean;
    } */
    

    Then you can make your RequiredProps type alias like this:

    type RequiredProps<T, K extends keyof T> = 
      Id<Omit<T, K> & Required<Pick<T, K>>>;
    

    And let's verify it gives you what you want:

    type UserWithPhoneNumber = RequiredProps<User, 'phoneNumber'>;
    /* type UserWithPhoneNumber = {
        id: string;
        phoneNumber: string;
    } */
    

    Looks good. It's possible that the properties that come out will be reordered compared to what comes in, which doesn't affect type compatibility at all. Hopefully that's fine because there's nothing I can think of which will make sure they appear in the same order.


    A word about Omit<T, K> & Required<Pick<T, K>>. Conceptually that is similar to what you were doing, except that I'm using the standard-library-provided utility types.

    But there's a difference; your {[P in Exclude<keyof T, K>]: T[P]} is no longer homomorphic, and therefore does not preserve the property modifiers from T. See microsoft/TypeScript#12563 for the description of how homomorphic mapped types preserve property modifiers. So if you do this:

    type U1 = RequiredPropsV1<User, "id">
    /* type U1 = {
        phoneNumber: string | undefined;
    } & {
        id: string;
    } */
    const u1: U1 = { id: "" }; // error! phoneNumber is required
    

    You'll see that your version ends up making all the properties required (but leaves undefined in the domain of the unmentioned properties). Presumably you'd want something more like:

    type U2 = RequiredProps<User, "id">
    /* type U2 = {
        phoneNumber?: string | undefined;
        id: string;
    } */
    const u2: U2 = { id: "" }; // okay
    

    where the required-ness of properties not mentioned won't change.


    Anyway, hope that helps; good luck!

    Playground link