Reputation: 6207
Perhaps the title is not clear, this is tough to summarize.
I have two interfaces, such as:
interface KeyA {
a: ValueA;
}
interface KeyB {
b: ValueB;
}
and a type which indicates a given value can be of KeyA
, KeyB
, or an empty object:
type KeyExpr = KeyA | KeyB | Record<string, never>;
Assuming I am given an instance of KeyExpr
type, I would to access one of it's properties (either a
or b
in this example) and have the resulting type be ValueA or ValueB accordingly:
function func(keyExpr: KeyExpr) {
const value = keyExpr['a'];
// how to make the type of "value" by ValueA here?
}
I am able to obtain the possible keys for KeyExpr
like so:
export type KnownKeys<T> = {
[K in keyof T]: string extends K ? never : number extends K ? never : K;
} extends infer R
? R extends { [_ in keyof T]: infer U }
? U
: never
: never;
const keys = KnownKeys<KeyExpr>;
...now the type of keys
is "a" | "b"
, but still accessing a property on something that is of KeyExpr
type yields the type never
(meaning even though I used a type "a" | "b"
to access it, it still resolved to that more generic Record type).
Any ideas how to improve this or just can't be done?
Thanks!
Upvotes: 1
Views: 145
Reputation: 329278
If you have a value whose type is a union of object types, you can use the in
operator to narrow the apparent type of that value. The type checker will automatically filter the union to just those members known to have or not known to have the key you're checking.
In the case of a value of type KeyExpr
, it looks like this:
function func(keyExpr: KeyExpr) {
if ("a" in keyExpr) {
// keyExpr narrowed to KeyA
const value = keyExpr.a // ValueA
} else if ("b" in keyExpr) {
// keyExpr narrowed to KeyB
const value = keyExpr.b // Value B
} else {
// keyExpr narrowed to Record<string, never>
keyExpr // Record<string, never>
}
That works how you'd like it.
Note that it is technically not type safe for the type checker to do this, since object types in TypeScript are open and values may have properties not known to the compiler. That is, TypeScript types are not "exact types", as discussed in microsoft/TypeScript#12936. So something like this is possible:
declare const valueB: ValueB;
const keyB = { a: "oops", b: valueB };
func(keyB); // no error
Here you've created a value named keyB
which is assignable to KeyB
but has an excess a
property. If you explicitly annotated this like const keyB: KeyB = {a: "oops", b: valueB }
, you'd get an excess property warning because the compiler would worry that you're throwing away the a
property. But without that annotation it's fine.
And then func(keyB)
compiles with no error because keyB
is a KeyB
which is a KeyExpr
. And then, unfortunately, the implementation of func()
goes down the wrong path because the compiler assumes that ("a" in keyExpr)
means that keyExpr
is a KeyA
. If that implementation did any KeyA
-specific things, you'd likely get a runtime error.
In practice this sort of thing is rare, so you might not need to worry about it.
But if you are worried about it, then you should really try to make your KeyExpr
type more robust. For example, you could make it a discriminated union. For example:
interface KeyA {
type: "A",
a: ValueA;
}
interface KeyB {
type: "B"
b: ValueB;
}
type KeyExpr = KeyA | KeyB | Partial<Record<string, never>>;
function func(keyExpr: KeyExpr) {
if (keyExpr.type === "A") {
const value = keyExpr.a // ValueA
} else if (keyExpr.type === "B") {
const value = keyExpr.b // Value B
} else {
keyExpr // Partial<Record<string, never>>
}
}
Now there is a strongly-typed type
property which will either be "A"
, "B"
, or undefined
, and can be used to unequivocally identify which member of the union a value is:
declare const valueB: ValueB;
const keyB = { type: "B" as const, a: "oops", b: valueB };
func(keyB); // okay now
Upvotes: 1