Reputation: 716
How can I describe a type that can be used as a type guard to exclude keys from an object?
Below I have a function getExcludedKeys
which filters out keys from the passed in object.
What I am experiencing is that the type guard just does nothing and I get all 3 properties through, what I would like it to do is narrow the type down so that it excludes the filtered keys from the type.
I have used type guards in filter
expressions before but never from a predefined array.
interface Foo {
id: number
val1: string
val2: string
}
type KeysOf<T> = (keyof T)[]
const getKeys = <T> (obj: T) => Object.keys(obj) as KeysOf<T>
const getExcludedKeys = <T> (obj: T, excludeKeys: KeysOf<T>) =>
getKeys(obj)
.filter((key): key is Exclude<keyof T, typeof excludeKeys> => { // This line isn't working as expected.
return !excludeKeys.includes(key)
})
const foo: Foo = {
id: 1,
val1: 'val1',
val2: 'val2'
}
const result = getExcludedKeys(foo, ['val1', 'val2'])
.map(key => key) // EXPECTED :: key: "id"
// ACTUAL :: key: "id" | "val1" | "val2"
Upvotes: 1
Views: 622
Reputation: 151380
You've given an self-answer and then put a bounty on your question and added this comment to your answer:
This all breaks if the array of excluded keys is defined in a variable/constant before being passed to exclude...
So I'm assuming that this is the problem you're concerned with. Indeed if I take your code, and do this, I get an error on the second argument of exclude
:
const toExclude = ["foo", "bar"];
const b = exclude(a, toExclude)
.map(key => { // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
if (key === 'bar') { } // Error
if (key === 'foo') { } // Error
if (key === 'baz') { } // Okay
if (key === 'yay') { } // Okay
});
Your issue is that when you declare a variable like this:
const toExclude = ["foo", "bar"];
TypeScript will infer the "best common type" (this terminology is from the documentation). The "best common type" the type that fits the most common usage scenarios. Generally when someone defines an array that contains string literals, the desired inferred type is string[]
. This is similar to how when you do const x = "moo";
the inferred type of x
is string
and not "moo"
. If you want a narrower type for it, you need to be explicit const x: "moo" = "moo"
. And similarly const x = 1
will infer number
for x
, though it could have a narrower type.
For you, that's a problem because your array has to contain values which are a subset of the possible keys on the first argument you pass to exclude
. You have to tell TS that you mean the array to have a narrower type. You could do it with:
const toExclude = ["foo", "bar"] as ["foo", "bar"];
If you have multiple arrays you need to type like this, it can get old to have to add type assertion every time. You could use a helper function to prevent from having to type out a type assertion each and every time. For instance:
function asKeysOfA<T extends (keyof typeof a)[]>(...values: T) { return values; }
const toExclude = asKeysOfA("foo", "bar");
toExclude
above is inferred to have the type ["foo", "bar"]
. Here's a version that would work with any object type and not just typeof a
:
function asKeysOf<O, T extends (keyof O)[]>(o: O, ...values: T) { return values; }
const toExclude = asKeysOf(a, "foo", "bar");
In this case you need to pass a
as the first argument so that T
can be properly inferred. a
is not used by the function.
You may be wondering why, when you do exclude(a, ["foo", "bar"])
you do not need to perform a type assertion because when inferring the type of the 2nd argument, TS uses a different method than the "best common type". It determines a "contextual type". If the structure can satisfy the type declared for the 2nd argument of exclude
, then TS infers that type rather than a more general type and you don't have to use a type assertion.
This is actually what the asKeysOfA
and asKeysOf
functions above are exploiting: when the values are passed as parameters to these functions TS infer for them a contextual type, which then carries over when inferring the return values of the helper functions.
I'm going to add a complete example here derived from your code, which includes the helper functions that provide a good narrow tuple type for the variable toExclude
. I did not see much benefit to the KeysList
, and Omit
type declarations. For me, they don't make the code easier to read. So I've removed them. The principle how of exclude
is typed remains the same though.
function getAllKeys<T>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
function exclude<T, K extends (keyof T)[]>(obj: T, excludes: K) {
return getAllKeys(obj)
.filter((key: keyof T): key is Exclude<keyof T, K[number]> =>
!excludes.includes(key));
}
const a = {
foo: 'abc',
bar: 'abc',
baz: 'abc',
yay: true
};
// function asKeysOfA<T extends (keyof typeof a)[]>(...values: T) { return values; }
// const toExclude = asKeysOfA("foo", "bar");
function asKeysOf<O, T extends (keyof O)[]>(o: O, ...values: T) { return values; }
const toExclude = asKeysOf(a, "foo", "bar");
const b = exclude(a, toExclude)
.map(key => { // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
if (key === 'bar') { } // Error
if (key === 'foo') { } // Error
if (key === 'baz') { } // Okay
if (key === 'yay') { } // Okay
});
TypeScript 3.4 is going to be released soon and provides some new functionality which affects what you're trying to do here. In TypeScript 3.4 it is possible to ask the compiler to infer the narrowest possible type for literal values where it would otherwise infer the "best common type". You have to use the type assertion as const
. So the toExclude
array could be declared like this:
const toExclude = ["foo", "bar"] as const;
Note that this also make the array readonly
. Consequently, the declaration of exclude
should also expect a readonly
array, so the definition of K
must change to K extends readonly ...
:
function exclude<T, K extends readonly (keyof T)[]>(obj: T, excludes: K) {
Here's an example:
function getAllKeys<T>(obj: T): (keyof T)[] {
return Object.keys(obj) as (keyof T)[];
}
function exclude<T, K extends readonly (keyof T)[]>(obj: T, excludes: K) {
return getAllKeys(obj)
.filter((key: keyof T): key is Exclude<keyof T, K[number]> =>
!excludes.includes(key));
}
const a = {
foo: 'abc',
bar: 'abc',
baz: 'abc',
yay: true
};
const toExclude = ["foo", "bar"] as const;
const b = exclude(a, toExclude)
.map(key => { // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
if (key === 'bar') { } // Error
if (key === 'foo') { } // Error
if (key === 'baz') { console.log("Q"); } // Okay
if (key === 'yay') {console.log("F"); } // Okay
});
This example behaves as expected with Typescript 3.4.0-rc
Upvotes: 2
Reputation: 716
After several hours of fiddeling I finally managed to crack it with a little help from @nucleartux with his Omit type.
All I needed was this wacky type Omit<T, K extends KeysList<T>> = Exclude<keyof T, K[number]>
as a type guard in combination with exclude
having a second generic type K extends (keyof T)[]
type KeysList<T> = (keyof T)[]
type Omit<T, K extends KeysList<T>> = Exclude<keyof T, K[number]>
function getAllKeys<T>(obj: T): KeysList<T> {
return Object.keys(obj) as KeysList<T>
}
function exclude<T, K extends KeysList<T>>(obj: T, excludes: K) {
const filterCallback = (key: keyof T): key is Omit<T, K> => // <-- THIS LINE
!excludes.includes(key)
return getAllKeys(obj)
.filter(filterCallback)
}
const a = {
foo: 'abc',
bar: 'abc',
baz: 'abc',
yay: true
};
const b = exclude(a, ['foo', 'bar'])
.map(key => { // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
if (key === 'bar') { } // Error
if (key === 'foo') { } // Error
if (key === 'baz') { } // Okay
if (key === 'yay') { } // Okay
});
Upvotes: 1