Reputation: 1480
How can I map values of object keys to the same object in TypeScript in a way that IntelliSense will know work as shown in the example below?
const obj = getByName([
{ __name: 'foo', baz: 'foobar' },
{ __name: 'bar', qux: 'quux' },
]);
obj.foo.__name // ok
obj.foo.baz // ok
obj.foo.quuz // not ok
obj.bar.__name // ok
obj.bar.qux // ok
obj.bar.quuz // not ok
Upvotes: 3
Views: 889
Reputation: 1480
The following answer is heavily based on @Linda Paiste's answer.
The difference is that instead of using plain objects, I'm using classes as substitutes. Quoting Linda:
In
KeyedByName
we say that the value for a key is can only be the elements of the array whose __name property matches the type of that key. But if the key type is just string this won't narrow at all.
By using classes I believe TypeScript goes through a much more effective way of inferring the types when unpacking them, since it seems to type-check the objects using the __types
properties mapped to the classes themselves. Instead of combining the plain objects into one, it effectively infers the correct type, resulting in KeyedByName
to resolve the following:
class Foo {
get __name(): 'foo' { return 'foo'; }
constructor(public baz: string) {}
}
class Bar {
public readonly __name: 'bar' = 'bar';
constructor(public qux: string) {}
}
type ObjectsByName = KeyedByName<typeof inputs>;
Where ObjectsByName
evaluates to:
type ObjectsByName = {
foo: Foo;
bar: Bar;
}
Upvotes: 1
Reputation: 42188
The part where this gets tricky is trying to maintain the relationship between the property names and the specific interface for that property. Ideally, property baz
would exists on the key foo
but not on key bar
.
I've got it semi-working, but it only works if you use __name: 'foo' as const
to say that the type of this name is the literal string 'foo'
only. Otherwise typescript sees each name's type as string
and the association between specific names and specific properties is lost.
// standard util to get element type of 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 KeyedByName
we say that the value for a key is can only be the elements of the array whose __name
property matches the type of that key. But if the key type is just string
this won't narrow at all.
If we use the 'foo' as const
notation, the return type KeyedByName
becomes highly specific.
const inputsConst = [
{ __name: 'foo' as const, baz: 'foobar' },
{ __name: 'bar' as const, qux: 'quux' },
];
type K1 = KeyedByName<typeof inputsConst>
Evaluates to
type K1 = {
foo: {
__name: "foo";
baz: string;
qux?: undefined;
};
bar: {
__name: "bar";
qux: string;
baz?: undefined;
};
}
We know that certain properties are required and that others don't exist (can only be undefined
).
const checkK1 = ( obj: K1 ) => {
const fooName: string = obj.foo.__name // ok
const fooBaz: string = obj.foo.baz // required to be string
const fooQux: undefined = obj.foo.qux // can access, but will always be undefined because it doesn't exist
const fooQuuz = obj.foo.quuz // error
const barName: string = obj.bar.__name // ok
const barQux: string = obj.bar.qux // required to be string
const barBaz: undefined = obj.bar.baz // can access, but will always be undefined because it doesn't exist
const barQuuz = obj.bar.quuz // error
}
However without using foo as const
this type is not any more specific than the Record
in @gurisko's answer because typescript sees the type of 'foo'
and 'bar'
both as string
and therefore they are equivalent.
const inputsPlain = [
{ __name: 'foo', baz: 'foobar' },
{ __name: 'bar', qux: 'quux' },
];
type K2 = KeyedByName<typeof inputsPlain>
Evaluates to:
type K2 = {
[x: string]: {
__name: string;
baz: string;
qux?: undefined;
} | {
__name: string;
qux: string;
baz?: undefined;
};
}
All properties are seen as optional regardless of whether they are from foo
or bar
.
const checkK2 = ( obj: K2 ) => {
const fooName: string = obj.foo.__name // ok
const fooBaz: string | undefined = obj.foo.baz // ok but could be undefined
const fooQux: string | undefined = obj.foo.qux // ok but could be undefined
const fooQuuz = obj.foo.quuz // error
const barName: string = obj.bar.__name // ok
const barQux: string | undefined = obj.bar.qux // ok but could be undefined
const barBaz: string | undefined = obj.bar.baz // ok but could be undefined
const barQuuz = obj.bar.quuz // error
}
Upvotes: 2
Reputation: 1182
You could go with the following:
export function getByName<
TName extends string,
TObj extends {__name: TName},
>(arr: TObj[]): Record<TName, TObj> {
return arr.reduce((acc, obj) => {
return {
...acc,
[obj.__name]: obj,
};
}, {} as Partial<Record<TName, TObj>>) as Record<TName, TObj>;
}
EDIT: The solution above has two issues:
string
(meaning obj.nonExistingKey.qux
will pass)obj.foo.qux
to be okay despite there is no {__name: 'foo', qux: 'value'}
in the provided array.To resolve the issue no1, we can pass the array as const
:
function getByName<TObj extends {__name: string}>(arr: readonly TObj[]) {
return arr.reduce((acc, obj) => {
return {
...acc,
[obj.__name]: obj,
};
}, {}) as Record<TObj['__name'], TObj>;
}
const obj = getByName([
{ __name: 'foo', baz: 'foobar' },
{ __name: 'bar', qux: 'quux' },
] as const);
obj.nonExistingKey.quuz // not ok NOW!
However, I am not aware of a solution for the issue no2 with generics. If it is really important to you, you can implement some type guards.
Upvotes: 3