In TypeScript I have a generic mapping service that requires a generic ComplexMappingType
. This type defines which source field is to be mapped to which target field – a mapping definition. As input, this generic type requires a SourceType (S)
and a TargetType (T)
. The goal is to create such mapping definitions with the related source and target fields in a typesafe way using this ComplexMappingType
– it should work even for nested types. Here is my current solution:
type FieldMapping<S> = {
source: keyof S;
};
// used for defining mappings with array properties, holding object values
type ArrayFieldMapping<S, T> = {
baseSource: keyof S;
itemMapping: ComplexMapping<S[keyof S] extends (infer U)[] ? U : never, T>;
};
type IsConfidenceType<T> = T extends { confidence: number } ? true : false;
// the mapping definition type
export type ComplexMapping<S, T> = {
[K in keyof T]: T[K] extends Array<infer Item>
? Item extends object
? ArrayFieldMapping<S, Item>
: never
: T[K] extends object
? IsConfidenceType<T[K]> extends true
? FieldMapping<S>
: {
[P in keyof T[K]]: T[K][P] extends object
? ComplexMapping<S, T[K]>[P]
: FieldMapping<S>;
}
: FieldMapping<S>;
};
Then you can create a mapping definition for i.e. the following specific Types:
type SourceType = {
aSource: string;
deliveryNotes: { name: string }[];
};
// removing the ? will make it work even in strictNullCheck=true
type TargetType = {
aTarget: string;
deliveryNotesTarget?: { lala: string }[];
};
// this can be defined in a typesafe way. You can not pick non existing properties
const mappingDefinition: ComplexMapping<SourceType, TargetType> = {
aTarget: {
source: "aSource",
},
deliveryNotesTarget: {
// here the typescript error occurs if strictNullCheck is enabled
baseSource: "deliveryNotes",
itemMapping: {
lala: { // type safety is only here if i use the as ComplexMapping approach
source: "name",
},
} as ComplexMapping<
SourceType["deliveryNotes"][number],
TargetType["deliveryNotesTarget"][number]
>,
},
};
The solution works only if strictNullChecks
is set to false
in tsconfig. But if I set it to true
, I got the following error on the baseSource
property field:
Object literal may only specify known properties, and ‘baseSource’
does not exist in type ‘FieldMapping’.(2353) input.tsx(39,
3): The expected type comes from property ‘deliveryNotesTarget’ which
is declared here on type ‘ComplexMapping<SourceType, TargetType>’
Something I already figured out: there seems to be some issue with the property deliveryNotesTarget?
. If you change it here to deliveryNotesTarget
it works, but in my case it has also to work with the optional deliveryNotesTarget?
. Setting strictNullChecks
to false
is also not an option.
Here is the code, which reproduces the issue:
Playground
3
It looks like your problem is that your conditional types of the form
T[K] extends Array<infer Item> ? ⋯ : T[K] extends object ? ⋯ : ⋯;
do not distribute over unions in the property type T[K]
. You can fix this by refactoring to a utility type, so that the checked type is its own generic type parameter instead of T[K]
, resulting in distributive conditional types:
UtilityType<T[K]>
where
type UtilityType<TK> = TK extends Array<infer Item> ? ⋯ : TK extends object ? ⋯ : ⋯;
When you change a property from required to optional, then T[K]
ends up gaining an undefined
in its domain. So if T[K]
were Array<X>
when K
is not optional, then it becomes Array<X> | undefined
when K
is optional.
But while Array<X> extends Array<infer Item>
is true, (Array<X> | undefined) extends Array<infer Item>
is false. So your code takes different paths for optional and required properties. So when deliveryNotesTarget
is required you get
type CheckReq = ComplexMapping<SourceType, Required<TargetType>>;
/* type CheckReq = {
aTarget: FieldMapping<SourceType>;
deliveryNotesTarget: ArrayFieldMapping<SourceType, {
lala: string;
}>;
} */
but when it is optional you get
type Check = ComplexMapping<SourceType, TargetType>;
/* type Check = {
aTarget: FieldMapping<SourceType>;
deliveryNotesTarget?: FieldMapping<SourceType> | undefined;
}*/
because your check against Array
failed.
I presume you’d like your types to distribute across unions, so that F<T | U | V>
evaluates to F<T> | F<U> | F<V>
, and that way F<Array<X> | undefined>
will evaluate to F<Array<X>> | F<undefined>
, and then the F<Array<X>>
code path will still be taken. That also means you need to make sure F<undefined>
does something reasonable, probably resulting in just undefined
itself (but this is out of scope for the question as asked; I don’t want to dive into the code to figure out what you mean to do here, you need to figure that out yourself).
So let’s refactor your ComplexMapping<S, T>
mapped type into something that just applies a utility type to its properties:
type ComplexMapping<S, T> = { [K in keyof T]: ComplexMappingProperty<S, T[K]> };
type ComplexMappingProperty<S, TK> =
TK extends Array<infer Item> ? (Item extends object ? ArrayFieldMapping<S, Item> : never) :
TK extends object ? (IsConfidenceType<TK> extends true ? FieldMapping<S> : {
[P in keyof TK]: TK[P] extends object ? ComplexMapping<S, TK>[P] : FieldMapping<S>;
}) :
TK extends undefined ? undefined :
FieldMapping<S>;
This is almost exactly the same as yours, except that ComplexMappingProperty<S, TK>
is distributive in unions across TK
, and that ComplexMappingProperty<S, undefined>
is undefined
(so we don’t have an extra FieldMapping<S>
in there when T
is undefined
).
Now when deliveryNotesTarget
is required nothing changes,
type CheckReq = ComplexMapping<SourceType, Required<TargetType>>;
/* type CheckReq = {
aTarget: FieldMapping<SourceType>;
deliveryNotesTarget: ArrayFieldMapping<SourceType, {
lala: string;
}>;
} */
but when it’s optional the same code path is taken so you get the expected type:
type Check = ComplexMapping<SourceType, TargetType>;
/* type Check = {
aTarget: FieldMapping<SourceType>;
deliveryNotesTarget?: ArrayFieldMapping<SourceType, {
lala: string;
}> | undefined;
} */
Looks good.
Note that there are other ways to proceed without using distributive conditional types. If you don’t care about unions other than | undefined
then you can always use the NonNullable
utility type to just remove undefined
, so you check NonNullable<T[K]> extends
instead of T[K] extends
. Distribution across unions is usually what people expect, though.
Playground link to code