Follow

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use
Contact

Typescript mapped type from Array

Just an example function:

// Merges objects | arrays
function merge(...values) {
  return Object.assign(
    {},
    ...values.map((value) =>
      Array.isArray(value)
        ? Object.fromEntries(value.map((val) => [val, null]))
        : value,
    ),
  )
}

merge({k1: 1}, {k2: 2}) // {k1: 1, k2: 2} - 👌
merge({k1: 1}, ['k2'])   // {k1: 1, k2: null} - 👌

I’m trying to figure out how to write types for the function and keep the structure of the result

// Types definition
export type MixType<T> = T extends string[]
  ? { [K in T[number]]: null }
  : { [K in Extract<keyof T, string>]: T[K] }

type Test1 = MixType<{k1: 1}> // Type is: {k1: 1} - 👌
type Test2 = MixType<['k1']>   // Type is: {k1: null} - 👌

// Bind types into the function
function merge<V1>(v: V1): MixType<V1>
function merge<V1, V2>(v1: V1, v2: V2): MixType<V1> & MixType<V2>
function merge(...values) { // ... }

const t1 = merge({k1: 1}, {k2: 2}) // typeof t1: {k1: number} & {k2: number} - 👌
const t2 = merge({k1: 1}, ['k2']) // typeof t2: {k1: number} & {[x:string]: null} - 🤷‍♂️
const t3 = merge(['k1']) // typeof t3: {[x: string]: null} - 🤷‍♂️

How to make the typescript keep the resulting structure with arrays? How I can understand T[number] and Extract<keyof T, string> are both produce a union. So it has to be the same {[K in <Union>} in both cases. But for arrays ts drops result structure.

MEDevel.com: Open-source for Healthcare and Education

Collecting and validating open-source software for healthcare, education, enterprise, development, medical imaging, medical records, and digital pathology.

Visit Medevel

So there are questions:

  1. how to make merge({k1: 1}, ['k2']) to get type of {k1: number} & {k2: null}
  2. how to make it even better: merge({k1: 1}, ['k2']) to get type of {k1: 1} & {k2: null}

>Solution :

Typescript errs on the side of not picking up string literals as generic types unless it is the direct generic: playground

function takeString<T extends string>(a:T): [T,T] {return [a,a]}
function takeAny<T>(a:T): [T,T] {return [a,a]}
function takeListOfStr<L extends string[]>(a:L): L {return a}

const typedAsSpecificallyHello = takeString("hello")
//  typed as ["hello", "hello"]
const typedAsString = takeAny("hello")
//  typed as [string, string]
const evenWorse = takeListOfStr(["hello", "hello"])
// typed just as string[]

This kind of makes sense, if a list of strings shows up somewhere it is reasonable to assume that the specific literals you put there don’t actually matter and it is just a list of strings. However as const completely overrides this behaviour: playground

function readsListOfStringsWithoutModifying<T extends readonly string[]>(a:T){return a}

const tt = readsListOfStringsWithoutModifying(["a", "a"] as const)

Since your function does guarentee the passed data is not modified you aren’t breaking any of typescripts internals and setting up your generics to accept a readonly array isn’t hard. So you would want to do something like this: playground

type UnionToIntersection<U> = // stolen from https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type
  (U extends any ? (k: U)=>void : never) extends ((k: infer I)=>void) ? I : never

type VALID_ARG = {[k:string]:unknown} | (readonly string[])
// Types definition
export type MixType<T extends VALID_ARG> = T extends readonly string[]
  ? Record<T[number], null>
  // here we are removing any readonly labels since we are creating a new object that is mutable
  // you could also just use `T` on this line if you are fine with readonly sticking around.
  : {-readonly [K in keyof T]: T[K] }

// Bind types into the function
function merge<Vs extends VALID_ARG[]>(...values:Vs): UnionToIntersection<MixType<Vs[number]>> {
    return Object.assign({}, ...values.map(
        (value) => Array.isArray(value)
                    ? Object.fromEntries(value.map((val) => [val, null]))
                    : value,
    ))
}

const t1 = merge({k1: 1}, {k2: 2})
//  this no longer  keeps 1,2, just stays `number`
const t2 = merge({k1: 1} as const, ['k2'] as const) 
// but adding `as const` makes everything retained

There are a few things going on here, first is that the generic is constrained to only be readonly string[] or an object with string keys which simplifies some of the filtering logic you had previously, second the function takes a list of these objects as the generic and passes Vs[number] to MixType, this gets the union of all arguments passed to distribute over the conditional type returning a union of partial object types, then using the (someone hacky) UnionToIntersection we get the original union produced by Vs[number] to instead represent an intersection of all the partial objects.

Add a comment

Leave a Reply

Keep Up to Date with the Most Important News

By pressing the Subscribe button, you confirm that you have read and are agreeing to our Privacy Policy and Terms of Use

Discover more from Dev solutions

Subscribe now to keep reading and get access to the full archive.

Continue reading