noisy
noisy

Reputation: 6783

How to define a function with second argument based on extracted type from mapping, based on a first argument?

I have a code, equivalent to this example:

export enum Group {
    FOO = 'foo',
    BAR = 'bar',
    BIZ = 'biz'
}

interface Mapping extends Record<Group, any> {
    [Group.FOO]: {fooString: string; fooNumber: number};
    [Group.BAR]: {barString: string; barDate: Date; notFoo: string};
    [Group.BIZ]: {bizBoolean: boolean; bizString: string; notFoo: string};
}

function method<T extends Group>(p0: T, p1: Mapping[T]) {
    if (p0 === Group.FOO) {
        // THE PROBLEM. This fails with: Property 'fooString' does not exist on type
        // '{ fooString: string; fooNumber: number; } | { barString: string; barDate: Date; } | { bizBoolean: boolean; bizString: string; }'.
        // Property 'fooString' does not exist on type '{ barString: string; barDate: Date; }'
        console.log(p1.fooString); 
    } else {
        // THE SAME PROBLEM: Property 'notFoo' does not exist on type 
        // '{ fooString: string; fooNumber: number; } | { barString: string; barDate: Date; notFoo: string; } | { bizBoolean: boolean; bizString: string; notFoo: string; }'.
        // Property 'notFoo' does not exist on type '{ fooString: string; fooNumber: number; }'
        console.log(p1.notFoo);
    }
}

// ok
method(Group.FOO, {fooString: '', fooNumber: 2});

// Fails as expected with: Type 'string' is not assignable to type 'number'. 
// The expected type comes from property 'fooNumber' which is declared here on type '{ fooString: string; fooNumber: number; }' 
method(Group.FOO, {fooString: '', fooNumber: 'test'});

// ok
method(Group.BAR, {barString: '', barDate: new Date(), notFoo: ''});

// Fails as expected with: Type 'number' is not assignable to type 'Date'.
// The expected type comes from property 'barDate' which is declared here on type '{ barString: string; barDate: Date; notFoo: string}'
method(Group.BAR, {barString: '', barDate: 42, notFoo: ''});  

The problem is, that within method, I get an error while trying to reference a property, which based on first argument and earlier if, should be available.

I was under an impression, that typescript should be able to deduce the fact, that console.log(p1.fooString); and console.log(p1.notFoo); in code above, are actually correct.

How this could be solved, without manual castings like:

if (p0 === Group.FOO) {
    console.log((p1 as Mapping[Group.FOO]).fooString);
} else {
    console.log((p1 as Mapping[Group.BIZ] | Mapping[Group.BAR] | Mapping[Group.QUX] | Mapping[Group.FRED]).notFoo);
}

(which would be really problematic in case of console.log(p1.notFoo); and an enum with much longer list of properties?

Upvotes: 4

Views: 110

Answers (1)

You need to make p0 and p1 as a part of one data structure:

export enum Group {
    FOO = 'foo',
    BAR = 'bar',
    BIZ = 'biz'
}

interface Mapping {
    [Group.FOO]: { fooString: string; fooNumber: number };
    [Group.BAR]: { barString: string; barDate: Date; notFoo: string };
    [Group.BIZ]: { bizBoolean: boolean; bizString: string; notFoo: string };
}

type Values<T> = T[keyof T]

type Params = Values<{
    [P in keyof Mapping]: [P, Mapping[P]]
}>

function method(...[p0, p1]: Params) {
    if (p0 === Group.FOO) {
        const test = p1.fooString // string, ok
    } else {
        p1.notFoo // string, ok, as expected because notFoo is shared between two types (BAR, BIZ)
    }
}

// ok
method(Group.FOO, { fooString: '', fooNumber: 2 });

// fails as expected
method(Group.FOO, { fooString: '', fooNumber: 'test' });

// ok
method(Group.BAR, { barString: '', barDate: new Date(), notFoo: '' });

// fail as expected
method(Group.BAR, { barString: '', barDate: 42, notFoo: '' });  

Playground

You need to make sure that illegal state of argument is unpresentable.

Type Params is a union of allowed states, this is why TS is able to infer it

Similar approach you can find in my article

Example with function overloading:

export enum Group {
    FOO = 'foo',
    BAR = 'bar',
    BIZ = 'biz'
}

interface Mapping {
    [Group.FOO]: { fooString: string; fooNumber: number };
    [Group.BAR]: { barString: string; barDate: Date; notFoo: string };
    [Group.BIZ]: { bizBoolean: boolean; bizString: string; notFoo: string };
}

type Values<T> = T[keyof T]

type Params = Values<{
    [P in keyof Mapping]: [P, Mapping[P]]
}>

function method(p0: Group.BAR, p1: Mapping[Group.BAR]): Mapping[Group.BAR]['barString']
function method(p0: Group.FOO, p1: Mapping[Group.FOO]): Mapping[Group.FOO]['fooString']
function method(...[p0, p1]: Params) {
    if (p0 === Group.FOO) {
        const test = p1.fooString // string, ok
        return p1.fooString
    } else {
        p1.notFoo // string, ok, as expected because notFoo is shared between two types (BAR, BIZ)
        return p1.notFoo
    }
}

// ok
method(Group.FOO, { fooString: '', fooNumber: 2 });

// fails as expected
method(Group.FOO, { fooString: '', fooNumber: 'test' });

// ok
method(Group.BAR, { barString: '', barDate: new Date(), notFoo: '' });

// fail as expected
method(Group.BAR, { barString: '', barDate: 42, notFoo: '' });  

Playground

Upvotes: 9

Related Questions