Search code examples
typescriptrecursiontransformationconditional-typesmapped-types

TypeScript mapped type that deep transforms with tuple/union support


I am trying to create a universal mapped type that achieves recursive type transformation.

Huge thanks to @jcalz for the elegant solution from https://stackoverflow.com/a/60437613/1401634.

(Note that ticket was with a different scope, and is not duplicate with this ticket)

As shown below, the current mapped type does not support tuple or union type.

Is there a way to support union types and make the specs pass?

Playground ready 👉Playground Link

/**
 * Recursive type transformation. Support scalar, object, array, and tuple within original type.
 * @example
 * DeepReplace<Original, [From, To] | [Date, string] | ...>
 */
type DeepReplace<T, M extends [any, any]> = T extends M[0] ?
  Replacement<M, T>
  :
  {
    [P in keyof T]: T[P] extends M[0]
    ? Replacement<M, T[P]>
    : T[P] extends object
    ? DeepReplace<T[P], M>
    : T[P];
  }

type Replacement<M extends [any, any], T> =
  M extends any ? [T] extends [M[0]] ? M[1] : never : never;

// Tests

const obj = {
  number: 1,
  date: new Date(),
  deep: { date: new Date() },
  arrayDeep: [{ date: new Date() }],
  array: [new Date()],
  tuple: [new Date(), 2, true],
  tupleWithObj: [{ date: new Date() }, 2, 'hi', { hello: 'world' }],
  tupleWithTuple: [[1, false], [2, new Date()], [3, { date: new Date() }]]
}

type ArrayType<A extends unknown[]> = $ElementType<A, number>

const date = new Date()
const number = 2
const n = null
const nestedArray = [[[new Date()]]]

const scalarTest: DeepReplace<typeof date, [Date, string]> = 'string' // ✅
const constTest: DeepReplace<typeof number, [Date, string]> = 2 // ✅
const primitiveTest: DeepReplace<typeof n, [Date, string]> = null // ✅
const nestedArrayTest: DeepReplace<typeof nestedArray, [Date, string]> = [[['string']]] // ✅

let o: DeepReplace<typeof obj, [Date, string]>

const innocentTest: typeof o.number = 2 // ✅
const shallowTest: typeof o.date = 'string' // ✅
const deepTest: typeof o.deep.date = 'string' // ✅
const arrayTest: ArrayType<typeof o.array> = 'string' // ✅
const arrayObjTest: ArrayType<typeof o.arrayDeep>['date'] = 'string' // ✅
const tupleTest: typeof o.tuple = ['string'] // ❌ Type 'string' is not assignable to type 'number | boolean | Date'.
const tupleObjTest: typeof o.tupleWithObj = { date: 'string' } // ❌ Object literal may only specify known properties, and 'date' does not exist in type '(string | number | { date: Date; soHard?: undefined; } | { soHard: string; date?: undefined; })[]'
const tupleTupleTest: typeof o.tupleWithTuple = [[1, false], [2, 'string'], [3, { date: 'string' }]] // ❌ Type 'string' is not assignable to type 'number | boolean | Date | { date: Date; }'; Type 'string' is not assignable to type 'Date'.


Solution

  • There are two parts (and two things needed to get them working)

    Union Types

    You would need to make use of the Extract and Exclude Utility types

    Tuple Types

    You need to use the infer keyword

    Put together:

    /**
     * Recursive type transformation. Support scalar, object, array, and tuple as original type.
     * @example
     * DeepReplace<Original, [From, To] | [Date, string] | ...>
     */
    type DeepReplace<T, M extends [any, any]> = T extends M[0] ?
      Replacement<M, T>
      :
      {
        [P in keyof T]: T[P] extends M[0]
        ? Replacement<M, T[P]>
        : T[P] extends (infer R)[] // Is this a Tuple or array
        ? DeepReplace<R, M>[] // Replace the type of the tuple/array
        : T[P] extends object
        ? DeepReplace<T[P], M>
        : Extract<T[P], M[0]> extends M[0] // Is this a union with the searched for type?
        ? UnionReplacement<M, T[P]> // Replace the union
        : T[P];
      }
    
    type Replacement<M extends [any, any], T> =
      M extends any ? [T] extends [M[0]] ? M[1] : never : never;
    
    type UnionReplacement<M extends [any, any], T> =
      DeepReplace<Extract<T, object>, M> // Replace all object types of the union
      | Exclude<T, M[0] | object> // Get all types that are not objects (handled above) or M[0] (handled below)
      | M[1]; // Direct Replacement of M[0]
    

    Playground

    Also for anyone reading this for converting objects, you still need to really convert them, this just changes the type for typescript and does not guarantee that you will get the correct object, YOU STILL NEED TO DO THE CONVERSION JS STYLE