Issue
I am trying to make a type alias that would recursively define a type with the following conditions:
- Remove
id
property - Make all properties partial
- Make one property required
- If the property type also has
id
, remove that and make all properties partial (except forname
if it exists)
For reference, this is the code:
type IDType = string | number;
type Person = { id: IDType };
type PartialAllExcept<
T, // extends Person,
R extends keyof T | string = 'name'
> = Partial<Omit<T, 'id' | R>> & R extends keyof T
? {
[K in R]: R extends keyof T
? T[R] extends Person
? PartialAllExcept<T[R]>
: T[R]
: any;
}
: {};
type Parent = Person & {
name: string;
single?: boolean;
};
type Child = Person & {
parent?: Parent | IDType;
name: string;
adult: boolean;
};
const data: PartialAllExcept<Child, 'parent'> = {
name: 'John Doe',
parent: { name: 'John Doe Sr' },
adult: true,
};
Despite the conditions, even if I were to hardcode the required property, say by doing
type PartialAllExcept<
T, // extends Person,
R extends keyof T | string = 'name'
> = Partial<Omit<T, 'id' | R>> & R extends keyof T
? {
[K in R]: { name: string };
}
: {};
That would still give me Property 'id' is missing in type '{ name: string; }' but required in type 'Person'
.
A few more notes on what I've investigated so far:
- Since
Child["parent"]
can be other values other thanParent
(that extendsPerson
) -IDType | undefined
, just keeping itParent
helps this problem, but my app would require it to beIDType | undefined
at places. Along with that, I get another error -Object literal may only specify known properties, and 'name' does not exist in type '{ parent: { name: string; }; }'
(more on this in the following points) - The generic
T
should extendPerson
, but for now is commented out - not a dealbreaker, but if that's included, then the recursive call gives -Type 'T[string]' is not assignable to type 'Person'
. - Since it's also a recursive call, and we wouldn't know what properties to make required (and others partial) for the property extending
Person
, the default"name"
is given, however that would give -Type 'string' does not satisfy the constraint 'keyof T'
. To suppress this,R
has been extended bystring
whereas ideally it should be justkeyof T
. That's fine, however, when there is a case when the value ofR
is not akeyof T
(sayname
property does not exist onT
), then that required field shouldn't be added - therefore the empty{}
at the end.
Solution
It's not explicitly mentioned in your list of requirements, but if an object has a property of a union type like Parent | string
, you want that union to be broken into its constituent elements, transformed individually, and then reassembled into a new union. That is, you want the operation to distribute over unions. But your version of the conditional type doing that property mapping code does not distribute over unions:
T[R] extends Person ? PartialAllExcept<T[R]> : T[R]
If T[R]
is Parent | string
, then the compiler evaluates this type as a single cohesize unit. Since Parent | string
does not extend Person
(if you had a value of type Parent | string
you couldn't safely assign it to a variable of type Person
), this conditional type evaluates to just T[R]
, which is Parent | string
. So instead of the Parent
part having optional properties, removed id
, etc., it stays as-is and you get an error.
So that's the primary reason why it doesn't work.
If you have a conditional type and you want it to distribute across unions, you need to turn it into a distributive conditional type. In order for this to happen, the type you're checking (the A
type in A extends B ? C : D
) needs to be a single type parameter. Since T[R]
is not a type parameter, it's not distributive. (T
is a type parameter and R
is a type parameter, but T[R]
is a combination of type parameters.)
An easy way to fix this is to refactor the offending conditional type into its own generic type where the checked type is a plain type parameter:
type Refactored<X, R extends string> =
X extends Person ? PartialAllExcept<X, R> : X;
and then use it:
Refactored<T[R]>
Now Refactored<Parent | string>
will distribute across Parent | string
. And you'll get (Parent extends Person ? PartialAllExcept<Parent, R> : Parent) | (string extends Person ? PartialAllExcept<string, R> : string)
which evaluates to PartialAllExcept<Parent, R> | string
as desired.
When I fix that (and some other minor things) I get the following type:
type PartialAllExcept<T extends Person, R extends string = 'name'> =
Partial<Omit<T, 'id' | R>> & (R extends keyof T ?
{ [K in R]: MaybePartialAllExcept<T[K], R> } : unknown
);
type MaybePartialAllExcept<T, R extends string> =
T extends Person ? PartialAllExcept<T, R> : T;
And now things work exactly the way you want:
const data: PartialAllExcept<Child, 'parent'> = {
name: 'John Doe',
parent: { name: 'John Doe Sr' }, // okay
adult: true,
};
Answered By - jcalz
0 comments:
Post a Comment
Note: Only a member of this blog may post a comment.