1

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.

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}

Consolidated answer

based on @TadhgMcDonald-Jensen response and comments from @TitianCernicova-Dragomir

type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (
  k: infer I,
) => void
  ? I
  : never

type MixType<T> = T extends readonly string[]
  ? { [K in T[number]]: null }
  : { [K in keyof T]: T[K] }

function merge<
  Vs extends Array<S[] | Record<S, V>>,
  S extends string,
  V extends string | number | boolean | object,
>(...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' })
// typeof t1: { k1: 1} & {k2: '2'} - 

const t2 = merge({ k1: true }, ['k2'])
// typeof t2: { k2: null} & {k1: true} - 
klen
  • 1,595
  • 12
  • 11

1 Answers1

1

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.

Tadhg McDonald-Jensen
  • 20,699
  • 5
  • 35
  • 59
  • Thank you for the great answer , it is much clear for me now. And thanks for the `UnionToIntersection` technique too. – klen Feb 17 '22 at 16:55
  • 1
    You can get around the as const for `["k2"]` by using a generic type parameter with a string constraint `function merge` – Titian Cernicova-Dragomir Feb 17 '22 at 16:59
  • @TitianCernicova-Dragomir it works but I don't understand how For me the definition: `` looks like `Vs` is an array of VALID_ARGS (Array) or an array of arrays of string (Array> – klen Feb 17 '22 at 17:15
  • 1
    @klen it's just an undocumented (or poorly documented) behavior. If the value is assigned to a type parameter contained to a literal producing type such as string or number then literal types are preserved in the tuple or array. `[S] |S[] ` is there to get the compiler in the mood to infer tuples... – Titian Cernicova-Dragomir Feb 17 '22 at 17:20
  • 1
    huh, actually that kind of makes sense! `S extends string` enables the behaviour to retain string literals so `S[]` would at least try to preserve literals. You don't actually need to preserve tupleness in this case so just `merge>, S extends string>` works. – Tadhg McDonald-Jensen Feb 17 '22 at 17:52
  • I wish there was a hack for object's values too – klen Feb 17 '22 at 18:11
  • Actually, it's possible for objects too, you just need to have object values to be literals (string|number|boolean|...):`merge>, S extends string, V extends number>` completely handles the example without using const – klen Feb 17 '22 at 18:28