I'm trying to create a type guard for narrowing Partial to non-Partial. For some reason I cannot compile it when it's generic, but it works without the generic type.
Suppose there's following setup:
type Foo = {
a: number
b?: string
c: string
}
let partialFoo: Partial<Foo> = {}
const doSomething = (foo: Foo) => {}
Meanwhile, somewhere else, I want to call function doSomething(..) with argument partialFoo while knowing partialFoo is assignable to Foo and I want to make compiler believe me. Normally it would complain about Argument of type 'Partial<Foo>' is not assignable to parameter of type 'Foo', so I created a type guard in order to narrow the type of variable partialFoo.
type DefinedProp<T, TProp extends keyof T> = { [P in TProp]-?: T[P] } & Omit<T, TProp>
const hasDefinedProps = <TProp extends keyof Partial<Foo>>(
partial: Partial<Foo>,
propNames: TProp[]
): partial is DefinedProp<Partial<Foo>, TProp> => {
return propNames.every(x => partial[x] !== undefined)
}
if(!hasDefinedProps(partialFoo,['a','c']))
{
throw new Error('props must be defined here')
}
doSomething(partialFoo) // fine, because partialFoo is DefinedProp<Partial<Foo>, 'a' | 'c'>, which is same as type { a: number, b?: string, c: string }
EDIT 1: (Added missing type DefinedProp<..> above)
The problem happens, when I want to get rid of dependency Foo in hasDefinedProps by making it more generic:
const hasDefinedProps = <T, TProp extends keyof T>(
partial: T,
propNames: TProp[]
): partial is DefinedProp<T, TProp> => { // Error: A type predicate's type must be assignable to its parameter's type.
return propNames.every(x => partial[x] !== undefined)
}
This fails to compile saying A type predicate's type must be assignable to its parameter's type.
EDIT 2: TypeScript playground
Any helps would be appreciated. Thanks
The definition for DefinedProp was not in the question, so I used this one:
type DefinedProp<T, Key extends keyof T> = T & Required<Pick<T, Key>>
after which hasDefinedProps type checks and works as expected:
const test = (foo: Foo) => {
if (hasDefinedProps(foo, [ 'b'])) {
const t = foo.b // string
} else {
const t = foo.b // string | undefined
}
}
So I guess the problem was in the definition of DefinedProp.
TypeScript playground
Update:
The definition of DefinedProp makes the question more interesting. As seen above, we can declare a type predicate
hasDefinedProps: <T, K extends keyof T>(o: T, keys: K[]) => o is DefinedProp<T, K>
with
type DefinedProp<T, K extends keyof T> = T & Required<Pick<T, K>>
In this type, T provides the properties for keys not in K, and overlapping optional properties with keys in K are harmless as they become required by intersecting with Required<Pick<T, K>> anyway.
The question is why a very similar type that omits the required properties:
type DefinedPropO<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
yields a type error:
A type predicate's type must be assignable to its parameter's type.
Type 'DefinedPropO<T, K>' is not assignable to type 'T'.
'T' could be instantiated with an arbitrary type which could be unrelated to 'DefinedPropO<T, K>'.(2677)
The first part of the error basically means that a type predicate cannot state that the parameter is something is cannot be according to its type. The second part often indicates there's an issue with unions.
It turns out DefinedPropO has some issues with unions. For example:
Type Test = DefinedPropO<{a:0, b?:0} | {a:1, b?: 1}, 'b'> // {a: 0 | 1, b: 0 | 1}
This type will allow {a:0, b:1}, which is not assignable to {a:0, b?:0} | {a:1, b?: 1}.
The reason why the value types are unions has to do with the fact that both Pick and Omit are non-homomorphic mapped types, which don't distribute over unions.
The interesting bit is that DefinedProp without Omit works fine, even though it also uses Pick. Why this happens becomes clear by manually evaluating the type:
type Test = DefinedProp<{a:0, b?:0} | {a:1, b?: 1}, 'b'>> // {a:0, b:0} | {a:1, b:1}
-> ({a:0, b?:0} | {a:1, b?: 1}) & {b:0 | 1}
-> {a: 0, b?: 0} & {b: 1 | 0} | {a: 1, b?: 1} & {b: 1 | 0}
-> {a: 0, b: 0} | {a: 1, b: 1}
So intersecting with T not only serves to provide the non-K properties, but also gets rid of the unions in the required properties.
To get things working with Omit, we can force distribution by adding a dummy extends clause (docs):
type DefinedPropD<T, K extends keyof T> =
T extends any ? Omit<T, K> & Required<Pick<T, K>> : never
type Test = DefinedPropD<{a:0, b?:0} | {a:1, b?: 1}, 'b'>
// {a: 0, b: 0} | {a: 1, b: 1}
Unfortunately, a type predicate that uses DefinedPropD still fails with the same error :-(
So even though the DefinedPropO has issues that can be fixed, TypeScript still cannot infer assignability to T for the fixed version. I couldn't find a GitHub issue that exactly matches this case, but it looks like it's just a limitation of type inference. For the DefinedProp without Omit, assignability is trivially true because of the intersection with T.
TypeScript playground
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With