Type a recursive mapped type?

Viewed 67

(not certain about that title)

I would like to accept arbitrarily nested objects and arrays, and split the primitives in all leaf positions into two, the goal being to handle "dirty" and "pristine" values of any type, while maintaining a uniform navigability through both the original and augmented structure.

A simple example would be like

[1]     -> [{dirty: 1, pristine: 1}]
{x:[1]} -> {x: [{dirty: 1, pristine: 1}]}

The type definition is seemingly the easy part. I have the following generic conditional type:

class DirtyPristinePrimitive<T> {
    dirty : T; pristine: T;
    constructor(v: T) {this.dirty = v; this.pristine = v;}
}

type DirtyPristine<T> = T extends string
    ? DirtyPristinePrimitive<string>
    : T extends Number
    ? DirtyPristinePrimitive<number>
    : T extends boolean
    ? DirtyPristinePrimitive<boolean>
    : T extends null
    ? DirtyPristinePrimitive<null>
    : T extends undefined
    ? DirtyPristinePrimitive<undefined>
    : T extends Array<infer U>
    ? Array<DirtyPristine<U>>
    : {[k in keyof T]: DirtyPristine<T[k]>};

The above appears to work if I explicitly pass in types and introspect them in the IDE, up to a few levels of nesting anyway.

The issue is writing a function to create values of this type from some T. I have:

function dirtyPristine<T>(v: T) : DirtyPristine<T> {
    if (Array.isArray(v)) {
        return v.map(e => dirtyPristine(e));
    }
    else if (isObject(v)) {
        const result = {};
        for (const key of Object.keys(v)) {
            (<any>result)[key] = dirtyPristine((<any>v)[key]);
        }
        return result;
    }
    else {
        return new DirtyPristinePrimitive(v);
    }
}

But at all 3 return statements the compiler errors with "Type ... is not assignable to DirtyPristine<T>".

Is this possible?

2 Answers

The problem is that the TypeScript compiler is essentially unable to evaluate assignability of conditional types that depend on unspecified generic type parameters.

When you call dirtyPristine() on a value of a specific type, the compiler can infer a specific type (like {x: string}) for T and therefore evaluate DirtyPristine<T> (like {x: DirtyPristinePrimitive<T>}). That's fine.

But inside the implementation of dirtyPristine(), the type parameter T is still unspecified. The compiler essentially gives up here. It defers the evaluation of DirtyPristine<T>, and therefore does not know how to determine if some value is actually assignable to DirtyPristine<T>.. unless the value is already known to be of the type DirtyPristine<T>.

This is a pain point in TypeScript, and it's not clear how it should be addressed. There's an open suggestion, microsoft/TypeScript#33912, to use control flow inside of the implementation to inform the compiler that return values are assignable to the conditional return type. If you want to see this happen, you might want to go to the issue and give it a . But it doesn't look like there's necessarily any movement there, and even if this does eventually happen, you need to proceed in the meantime.

The workaround here is to just use type assertions if you're sure that your code produces valid instances of the intended conditional type. Or, since using type assertions on every return line is annoying, you can do the moral equivalent and give dirtyPristine() a single-call-signature overload and have the implementation signature be loose enough to quiet the warnings (like (v: any) => any):

function dirtyPristine<T>(v: T): DirtyPristine<T>;
function dirtyPristine(v: any): any {
  if (Array.isArray(v)) {
    return v.map(e => dirtyPristine(e));
  }
  else if (isObject(v)) {
    const result: any = {};
    for (const key of Object.keys(v)) {
      result[key] = (dirtyPristine(v) as any)[key];
    }
    return result;
  }
  else {
    return new DirtyPristinePrimitive(v);
  }
}

Note that assertions like this mean that you have taken the responsibility away from the compiler for verifying type safety. It won't warn you if you make a mistake, so be careful.


Anyway, hope that helps; good luck!

Playground link

I don't see a better solution than the one from @jcalz well explained.

This is a suggestion fixing one issue ((dirtyPristine(v) as any)[key] should be dirtyPristine((v as any)[key]) to match the type definition of DirtyPristine) and including some improvements (IMO but it's a matter of taste) :

  • DirtyPristinePrimitive class should be restricted to contained only primitive value by design → class DirtyPristinePrimitive<T extends primitive | nil> using the 2 type aliases primitive and nil to ease reading the code
  • In DirtyPristinePrimitive class, I prefer having a (private if necessary) constructor initializing dirty and pristine properties and a factory method to create an instance from a single value.
  • DirtyPristine type definition can be simplified using primitive and nil aliases and another type alias DirtyPristineObject.
  • for (const key of Object.keys(v)) block can be shorten with reduce
type nil = null | undefined;
type primitive = boolean | number | string;

class DirtyPristinePrimitive<T extends primitive | nil> {
  static create<T extends primitive | nil>(value: T) {
    return new DirtyPristinePrimitive(value, value);
  }

  private constructor(
    public dirty: T,
    public pristine: T,
  ) {}
}

type DirtyPristineObject<T extends object> = {
  [k in keyof T]: DirtyPristine<T[k]>
};

type DirtyPristine<T> =
  T extends primitive | nil ? DirtyPristinePrimitive<T> :
  T extends Array<any> ? Array<DirtyPristine<any>> :
  T extends object ? DirtyPristineObject<T> :
  never;

function dirtyPristine<T>(v: T): DirtyPristine<T>;
function dirtyPristine(v: any): any {
  switch (typeof v) {
    case 'boolean':
    case 'number':
      return DirtyPristinePrimitive.create(v);

    case 'object':
      if (v == null) {
        return DirtyPristinePrimitive.create(v);
      }
      if (Array.isArray(v)) {
        const res = v.map(e => dirtyPristine(e) as DirtyPristine<any>);
        return res;
      }
      return Object.keys(v).reduce(
        (result, key) => ({ ...result, [key]: dirtyPristine((v as any)[key]) }),
        {} as any);

    default:
      throw new Error('Unsupported type');
  }
}
Related