The challenge arises when trying to uphold the connection between property names and their corresponding interfaces. It is desired for property baz
to exist under key foo
, but not under key bar
.
I've managed to make it partially functional, but only if you specify __name: 'foo' as const
to indicate that the type of this name should be exactly the string 'foo'
. Otherwise, TypeScript interprets each name's type as string
, leading to a loss in specificity regarding the association between specific names and properties.
// utility function to extract element types from an array
type Unpack<T> = T extends (infer U)[] ? U : never;
type KeyedByName<U extends {__name: string}[]> = {
[K in Unpack<U>['__name']]: Extract<Unpack<U>, {__name: K}>
}
In the KeyedByName
definition, we establish that the value for a key can only be the elements from the array whose __name
property matches the type of that key. However, if the key type is just string
, this constraint will not be enforced.
When using the notation 'foo' as const
, the resulting type of KeyedByName
becomes highly specific.
const inputsConst = [
{ __name: 'foo' as const, baz: 'foobar' },
{ __name: 'bar' as const, qux: 'quux' },
];
type K1 = KeyedByName<typeof inputsConst>
This results in:
type K1 = {
foo: {
__name: "foo";
baz: string;
qux?: undefined;
};
bar: {
__name: "bar";
qux: string;
baz?: undefined;
};
}
We are now able to determine which properties are required and which ones do not exist (can only be undefined
).
const checkK1 = ( obj: K1 ) => {
const fooName: string = obj.foo.__name // valid
const fooBaz: string = obj.foo.baz // must be a string
const fooQux: undefined = obj.foo.qux // accessible, but always undefined since it does not exist
const fooQuuz = obj.foo.quuz // error
const barName: string = obj.bar.__name // valid
const barQux: string = obj.bar.qux // must be a string
const barBaz: undefined = obj.bar.baz // accessible, but always undefined since it does not exist
const barQuuz = obj.bar.quuz // error
}
However, without utilizing foo as const
, this type is no more specific than the generic Record
mentioned in @gurisko's response because TypeScript sees both 'foo'
and 'bar'
as having type string
, thereby considering them equivalent.
const inputsPlain = [
{ __name: 'foo', baz: 'foobar' },
{ __name: 'bar', qux: 'quux' },
];
type K2 = KeyedByName<typeof inputsPlain>
Which leads to:
type K2 = {
[x: string]: {
__name: string;
baz: string;
qux?: undefined;
} | {
__name: string;
qux: string;
baz?: undefined;
};
}
In this case, all properties are considered optional regardless of whether they belong to foo
or bar
.
const checkK2 = ( obj: K2 ) => {
const fooName: string = obj.foo.__name // valid
const fooBaz: string | undefined = obj.foo.baz // valid, but could also be undefined
const fooQux: string | undefined = obj.foo.qux // valid, but could also be undefined
const fooQuuz = obj.foo.quuz // error
const barName: string = obj.bar.__name // valid
const barQux: string | undefined = obj.bar.qux // valid, but could also be undefined
const barBaz: string | undefined = obj.bar.baz // valid, but could also be undefined
const barQuuz = obj.bar.quuz // error
}
Playground Link