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:
- how to make
merge({k1: 1}, ['k2'])to get type of{k1: number} & {k2: null} - 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.