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} - 👍

1 Answer 1

2

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.

Sign up to request clarification or add additional context in comments.

7 Comments

Thank you for the great answer 🙏, it is much clear for me now. And thanks for the UnionToIntersection technique too.
You can get around the as const for ["k2"] by using a generic type parameter with a string constraint function merge<Vs extends (VALID_ARG| [S]| S[])[], S extends string>
@TitianCernicova-Dragomir it works but I don't understand how 😲 For me the definition: <Vs extends (VALID_ARG | S[])[], S extends string> looks like Vs is an array of VALID_ARGS (Array<VALID_ARGS>) or an array of arrays of string (Array<Array<string>>
@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...
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<Vs extends Array<S[]|Record<string,unknown>>, S extends string> works.
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.