Search code examples
typescripttuplestypescript-genericstype-constraintsmapped-types

How to type tuple array with corresponding types?


Could anyone help me with this unusual (as I think) problem?

Initially, I was implementing typed Map.

export enum KeyType {
  aa = 'aa',
  bb = 'bb'
}

export interface ValueTypes {
  aa: boolean,
  bb: string
}

interface TypedMap extends Map<KeyType, ValueTypes[KeyType]> {
  get<TK extends KeyType>(k: TK): ValueTypes[TK] | undefined
  set<TK extends KeyType>(k: TK, v: ValueTypes[TK]): this
}

The code above works well. Then I wanted to implement a function that able to set multiple values to this map:

function setMany<TKey, TVal> (
  map: Map<TKey, TVal>,
  change: (
    Map<TKey, TVal> |
    [TKey, TVal][]
  )
): void {
  const entries = change instanceof Map ? change.entries() : change

  for (const [key, value] of entries) {
    map.set(key, value)
  }
}

How can I type [TKey, TVal][] tuple array so that I have corresponding type check on input? Like:

const tm = new Map() as TypedMap

setMany(
  tm,
  [
    [KeyType.aa, 'Foo'], // Error, aa requires boolean
    [KeyType.bb, 'Bar'] // Nice, bb requires string
  ]
)

As I understand, I need something like this:

type ChangeTuple<TK extends KeyType> = [TK, ValueTypes[TK]]

but to work with argument and array. I tried this:

interface setManyV2 {
  (
    map: TypedMap,
    change: (
      TypedMap |
      ChangeTuple[] // requires Generic type, but ChangeTuple<KeyType>[] doesn't work because TK doesn't extend anymore
    )
  ): void
}

Test cases:

setMany(
  tm,
  [
    [KeyType.aa, true], // correct, aa requires boolean
    [KeyType.aa, false], // correct, aa requires boolean
    [KeyType.bb, 'any string'], // correct bb requires string
    [KeyType.aa, undefined], // incorrect, aa requires boolean, not undefined
    [KeyType.aa, new Set()], // incorrect, aa requires boolean, not Set
    [KeyType.aa, 'any string'], // incorrect, aa requires boolean, not string
    [KeyType.bb, new Set()], // incorrect, aa requires string, not Set
    [KeyType.bb, undefined], // incorrect, aa requires string, not undefined
    [KeyType.bb, false], // incorrect, aa requires string, not boolean
    [KeyType.bb, true] // incorrect, aa requires string, not boolean
  ]
)

const tm2 = new Map() as TypedMap

tm2.set(KeyType.aa, true)
tm2.set(KeyType.bb, 'string')

setMany(
  tm,
  tm2
)

Solution

  • I assume that you want to overload your Map implementation by TypedMap. If yes, there is a better way to do it.

    In order to overload your Map you need to intersect Map types, like this: Map<'a', boolean> & Map<'b', string>.

    Since you have KeyType and ValueTypes, we can merge them into one data structure:

    
    type MapOverloading<Keys extends string, Values extends Record<Keys, unknown>> = {
        [Prop in Keys]: Map<Prop, Values[Prop]>
    }
    
    
    // type TypedMap = {
    //     aa: Map<KeyType.aa, boolean>;
    //     bb: Map<KeyType.bb, string>;
    // }
    type TypedMap = MapOverloading<KeyType, ValueTypes>
    

    Now in order to produce Map overloading, we need to obtain a union of TypedMap object values. It is simple, just use this util:

    type Values<T> = T[keyof T]
    

    Now we need to convert union to intersection (UnionToIntersection):

    // credits goes to https://stackoverflow.com/a/50375286
    type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
        k: infer I
    ) => void
        ? I
        : never;
    
    type MapOverloading<Keys extends string, Values extends Record<Keys, unknown>> = {
        [Prop in Keys]: Map<Prop, Values[Prop]>
    }
    
    type Values<T> = T[keyof T]
    
    type TypedMap = UnionToIntersection<Values<MapOverloading<KeyType, ValueTypes>>>
    

    More about creating dynamic overloadings you can find here TypedMap is an overloaded Map data structure. Lets' test it:

    const tm: TypedMap = new Map();
    
    tm.set(KeyType.aa, true) // ok
    tm.set(KeyType.bb, true) // expected error
    

    COnsider this example:

    export enum KeyType {
        aa = 'aa',
        bb = 'bb'
    }
    
    export interface ValueTypes {
        aa: boolean,
        bb: string
    }
    
    
    // credits goes to https://stackoverflow.com/a/50375286
    type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
        k: infer I
    ) => void
        ? I
        : never;
    
    type MapOverloading<Keys extends string, Values extends Record<Keys, unknown>> = {
        [Prop in Keys]: Map<Prop, Values[Prop]>
    }
    
    type Values<T> = T[keyof T]
    
    type TypedMap = UnionToIntersection<Values<MapOverloading<KeyType, ValueTypes>>>
    
    const tm: TypedMap = new Map();
    
    tm.set(KeyType.aa, true) // ok
    tm.set(KeyType.bb, true) // expected error
    
    type IsNever<T> = [T] extends [never] ? true : false
    
    type TupleToMap<Tuple extends any[], ResultMap extends Map<any, any> = never> =
        Tuple extends []
        ? ResultMap
        : Tuple extends [[infer Key, infer Value], ...infer Rest]
        ? IsNever<ResultMap> extends true ? TupleToMap<Rest, Map<Key, Value>> : TupleToMap<Rest, ResultMap & Map<Key, Value>>
        : never
    
    type Validation<Tuple extends any[], CustomMap> = CustomMap extends TupleToMap<Tuple> ? Tuple : []
    
    function setMany<
        Key extends string,
        Value,
        CustmoMap extends Map<Key, Value>,
        Tuple extends [Key, Value],
        Change extends Tuple[],
        >(
            map: CustmoMap,
            change: Validation<[...Change], CustmoMap>,
    ): void {
    
        for (const [key, value] of change) {
            map.set(key, value)
        }
    }
    
    setMany(
        tm,
        [
            [KeyType.aa, true], // ok
            [KeyType.aa, false] // ok
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.bb, 's'], // ok
            [KeyType.bb, 'hello'], // ok
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.bb, 's'], // ok
            [KeyType.aa, true, // ok
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.aa, undefined] // expected error
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.aa, new Set()] // expected error
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.bb, undefined] // expected error
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.bb, new Set()] // expected error
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.bb, false] // expected error
        ]
    )
    setMany(
        tm,
        [
            [KeyType.bb, true] // expected error
        ]
    )
    
    setMany(
        tm,
        [
            [KeyType.aa, 'any string'] // expected error
        ]
    )
    
    

    Playground Above example answers on your first question: How to make it work with tuples.

    However there is a drawback, if one of tuple element is invalid, whole tuple will be highlighted

    Please let me know if it works for you. If it is, I will provide more explanation.