Type-safe generic mapping with optional fields failing under strictNullChecks

  Kiến thức lập trình

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

Theme wordpress giá rẻ Theme wordpress giá rẻ Thiết kế website Kho Theme wordpress Kho Theme WP Theme WP

LEAVE A COMMENT