htulipe
htulipe

Reputation: 1585

Typescript, automatically infer type from object keys

I want to declare an interface for a Select component that can be a multiple or single items select.

interface MySelect<T extends boolean> {
    multi: T, // Is this a multiple items select
    onChange: (item: T extends true ? string[]: string) => void // the onChange signature differs according to T
}

It works but I have to explicitly sets the generic type T:

const mySelect: MySelect<true> = { // Here
    multi: true, // And here
    onChange: (items) => {}
}

I would like to know if it's possible to have TS automatically infer T from the "multi" value:

const mySelect: MySelect = {
    multi: true, // multi is true so T is true
    onChange: (items) => {}
}

Important update: I want "multiple" to be optional (it would default to false if key is missing or undefined)

Upvotes: 1

Views: 1639

Answers (2)

T.J. Crowder
T.J. Crowder

Reputation: 1074295

You've updated your question to say that multi should be optional (default false). That rules out a discriminated union (the previous answer under the horizontal line below).

I think I'd use two interfaces you union together, and (if necessary) a base interface for the things they have in common. You'll probably want type guard functions for when you need to know the type of the select.

// Things all MySelects have in common (if you have anything other than `onChange`)
interface MySelectBase {
    name: string;
}
// A single-select version of MySelect
interface MySingleSelect extends MySelectBase {
    multi?: false;
    onChange: (item: string) => void;
}
// A multi-select version of MySelect
interface MyMultiSelect extends MySelectBase {
    multi: true;
    onChange: (items: string[]) => void;
}
// The unified type
type MySelect = MySingleSelect | MyMultiSelect;

// Type guard function to see whether it's a single select
const isSingleSelect = (select: MySelect): select is MySingleSelect => {
    return !select.multi; // !undefined and !false are both true
};

// Type guard function to see whether it's a multi select
const isMultiSelect = (select: MySelect): select is MyMultiSelect => {
    return !!select.multi; // !!undefined and !!true are both true
};

Examples of creation:

const single: MySingleSelect = {
    name: "some-single-select-field",
    onChange : (item) => { console.log(item); }
};

const multi: MyMultiSelect = {
    multi: true,
    name: "some-multi-select-field",
    onChange : (items) => { console.log(items); }
};

Example of using MySelect (the combined interface):

const useMySelect = (select: MySelect) => {
    // No need for a guard on anything but `onChange`
    console.log(select.name);
    // `onChange` will be a union type until/unless you use a type guard
    const onChange = select.onChange;
    //    ^^^^^^^^−−−−−−−−−− type is `((item: string) => void) | ((items: string[]) => void)`
    if (isSingleSelect(select)) {
        // It's a MySingleSelect
        const onChange = select.onChange;
        //    ^^^^^^^^−−−−−−−−−− type is `(item: string) => void`
    } else {
        // It's a MyMultiSelect
        const onChange = select.onChange;
        //    ^^^^^^^^−−−−−−−−−− type is `(items: string[]) => void`
    }
};

Playground link


This is the original answer for folks who don't have the requirement of making multi optional:

You can do that by declaring MySelect as a union of types, one with multi: true and the other with multi: false:

type MySelect =
    {
        multi: true;
        onChange: (items: string[]) => void;
    }
    |
    {
        multi: false;
        onChange: (item: string) => void;
    };

Then you get:

const mySelect: MySelect = {
    multi: true,
    onChange: (items) => {}
//  ^^^^^^^^−−−−−−−−−−− correctly inferred as (items: string[]) => void
};

Playground link

That's called a discriminated union: a union of types that are discriminated (told apart) by the type of one (or more) of the fields.

If you had a large set of other properties that didn't vary, you could add them to the discriminated union by using an intersection:

type MySelect =
    (
        {
            multi: true;
            onChange: (items: string[]) => void;
        }
        |
        {
            multi: false;
            onChange: (item: string) => void;
        }
    )
    &
    {
        the:        number;
        other:      string;
        properties: string;
    };

Upvotes: 5

kaya3
kaya3

Reputation: 51034

You can do this using a generic identity function:

function helper<T extends boolean>(obj: MySelect<T>): MySelect<T> {
    return obj;
}

// MySelect<true> inferred
const mySelect = helper({
    multi: true,
    onChange: (items) => {}
});

Playground Link

That said, this technique is more useful when your type parameter doesn't have a finite set of distinct options; in your case, T extends boolean is probably better implemented as a tagged union type as in T.J. Crowder's answer, without generics.

Upvotes: 2

Related Questions