Search code examples
typescriptmapped-types

Retrieve subset of object where string values start with given string


Given an object

const MyObject = {
    a: 'a',
    b1: 'b1',
    b2: 'b2',
} as const;

I want to create a type for an object which only contains its string values which start with the letter "b". How can I kind-of filter an object type by the contents of it's string values?

This is the type I want to construct out of typeof MyObject:

type OnlyBs = {
    b1: "b1";
    b2: "b2";
}

In an attempt to get the desired solution, I used template literal types and the infer keyword:

type OnlyBs = {
    [K in keyof typeof MyObject]: typeof MyObject[K] extends `b${infer Rest}` ? `b${Rest}` : never;
};

This, however, gives me the type

type OnlyBs = {
    readonly a: never;
    readonly b1: "b1";
    readonly b2: "b2";
}

which is close to what I want, but not the desired type. Hence, using it here with an object MyObject2 gives me the type error:

const MyObject2: OnlyBs = {
    b1: 'b1',
    b2: 'b2',
};

"Property 'a' is missing in type '{ b1: "b1"; b2: "b2"; }' but required in type 'OnlyBs'.(2741)"

The issue is that TypeScript requires me to add the a property, although its type is never. I can't come up with another way to achieve my goal. I feel like I'm close but I can't get the entire property (including its key) removed from the resulting object type.

Btw: In case it's easier to do, a solution which constrains the objects' keys (instead of the property values) would also work for me because in this particular example the property values equal the property keys.

TypeScript Playground of my code example


Solution

  • Try to return K in OnlyBs instead of b${Rest}. In this way, OnlyBs returns allowed keys. Then just use Pick to obtain desired props.

    Consider this example:

    const MyObject = {
        a: 'a',
        b1: 'b1',
        b2: 'b2',
    } as const;
    
    type AllowedKeys<Obj> = {
        [K in keyof Obj]: Obj[K] extends `b${infer _}` ? K : never; // <-- use K instead of `b{infer Rest}`
    }[keyof Obj]; // use extra keyof Obj
    
    {
        // "b1" | "b2" <-- allowed keys
        type _ = AllowedKeys<typeof MyObject>
    
    }
    
    type OnlyBs<Obj> = Pick<Obj, AllowedKeys<Obj>>
    
    {
        // readonly b1: "b1";
        // readonly b2: "b2";
        type _ = OnlyBs<typeof MyObject>
    }
    
    const MyObject1: OnlyBs<typeof MyObject> = {
        b1: 'b1',
        b2: 'b2',
    }; // ok
    
    const MyObject2: OnlyBs<typeof MyObject> = {
        a: null as any, // expected error
        b1: 'b1',
        b2: 'b2',
    };
    
    

    Playground

    If you want to make it in one utility type, consider this:

    type AllowedKeys<Obj> = Pick<Obj, {
        [K in keyof Obj]: Obj[K] extends `b${infer _}` ? K : never;
    }[keyof Obj]>; 
    

    Why does [keyof Obj] at the end of the mapped type AllowedKeys not also retrieve "a"? What's this concept called? Without [keyof Obj] and a call of keyof later, "a" would be there.

    Let's remove [keyof Obj]:

    const MyObject = {
        a: 'a',
        b1: 'b1',
        b2: 'b2',
    } as const;
    
    type AllowedKeys<Obj> = {
        [K in keyof Obj]: Obj[K] extends `b${infer _}` ? K : never; // <-- use K instead of `b{infer Rest}`
    };
    
    {
        // {
        //     readonly a: never;
        //     readonly b1: "b1";
        //     readonly b2: "b2";
        // }
        type _ = AllowedKeys<typeof MyObject>
    }
    
    

    As you might have noticed, we are getting an object where if value exists it is allowed key. If key is not allowed it is never. This is why a is never. Now. let's try to use square bracket notation with our result. It works for types in the same way as it works for runtime values in plain js:

    {
        // {
        //     readonly a: never;
        //     readonly b1: "b1";
        //     readonly b2: "b2";
        // }
        type a = AllowedKeys<typeof MyObject>['a'] // never
        type b = AllowedKeys<typeof MyObject>['b1'] // b1, because keys and allowed values are the same
        type ab = AllowedKeys<typeof MyObject>['a' | 'b1'] // still "b1" because any union with never does not make any sense
    }
    
    

    As you see, ab type returns b1 instead of b1 | never. Why? Here you will find an answer. Hence using AllowedKeys<typeof MyObject>[keyof typeof MyObject] retuns b1 | b2 because TS compiler removes never.

    One more thing, return union type is not a union of keys, it is a union of object values.

    Here you have an utility type to obtain a union of all object values: type Values<T>=T[keyof T]