Reputation: 6783
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
Reputation: 33061
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: '' });
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: '' });
Upvotes: 9