0

Given:

interface Language {
  nicknames: Record<string, string>;
}

const languages: Language[] = [
  {
    nicknames: {
      william: 'bill',
    },
  },
  {
    nicknames: {
      alexander: 'sasha',
    },
  },
];

Is it possible to write a function that takes languages and returns a type as follows:

{
  william: 'bill',
  alexander: 'sasha'
}

I know how to do this if languages isn't typed, and if the array is provided as const. But can't quite figure out how to do this with either is in place.

Izhaki
  • 23,372
  • 9
  • 69
  • 107
  • 2
    You will either have to use const assertion or pass the array directly (without the `languages` variable) and using const type parameter. Will either of these work for you? – wonderflame Jul 04 '23 at 23:31
  • Not sure what you mean by either options. Some working code would be appreciated. – Izhaki Jul 04 '23 at 23:46
  • Sure, [here](https://tsplay.dev/NaRvpm) – wonderflame Jul 04 '23 at 23:58
  • @Izhaki why not to use `as const` ? – Minsky Jul 05 '23 at 00:04
  • Thanks @wonderflame. Really useful, and although I've managed the same in a slightly different way, the utilities `UnionToIntersection` and `Prettify` are great. Thing is, in your solution `as const satisfies readonly Language[]` is used (one way or another), and this is exactly what I wouldn't like. The function `getNicknames` is a public API - and I would like consumers to type the input without having to add `as const satisfies readonly Language[]`. – Izhaki Jul 06 '23 at 22:46
  • What's more, in actuality, the API is more complex so it is imperative that what is provided to the function is typed. Sure, I can create helper wrapper functions that apply the `as const satisfies`, but ideally, and that was the question really, can we do without? I'll flesh out some more details in the question soon. – Izhaki Jul 06 '23 at 22:50
  • @Izhaki there will be no other way to make it work. Either consumers use `as const` or pass the literal array directly to the function. – wonderflame Jul 06 '23 at 23:04
  • Well. I did manage to do this without `as const` - using generics (where the generic is the value of the nickname; eg, `bill`). But this breaks if you type the input to the function, which is first part of the problem - I wonder if there's a way to tell typescript: "You got type information alright, but ignore it" - this is what `satisfies` does. But in practice each `Language` is an extension, not user code, so it has to be typed; so extensions can use `satisfies`, but it's all going to end up very odd. So I wonder if there is another option. – Izhaki Jul 06 '23 at 23:32
  • @wonderflame "there will be no other way to make it work.". I'll take your word that there is no other way, whilst also raising an issue on the typescript repo. Could you please post an answer so I can accept it? Something like, "this is not doable unless you use satisfies and const". Thanks! – Izhaki Jul 10 '23 at 19:26
  • sure, will do soon – wonderflame Jul 10 '23 at 19:35
  • 1
    https://github.com/microsoft/TypeScript/issues/54958 @wonderflame would also like to point to this, which does same thing as your proposal, but in a slightly different way: https://github.com/voodoocreation/ts-deepmerge/blob/master/index.ts – Izhaki Jul 10 '23 at 20:32

4 Answers4

2

TLDR: There is no way to do this unless you pass untyped arguments and use const and satisfies - either on the passed arguments, or within a wrapper function.


Let's start with some examples:

// number
let case1 = 1;
// 1
const case2 = 1;

With let the value can change, thus the type is widened to the number primitive, since it can change, however, with the const it can't, and because number is a primitive type we get the exact value as the type.

More examples:

// {a: string}
let case3 = {
    a: 'str'
}

// {a: string}
const case4 = {
    a: 'str'
}

In the case of non-primitive types like JS objects, there is no difference between the let and const, which I think is how Typescript is designed. When an object is defined with const, we can't assign something else to the variable, however, we can still mutate its properties.

To prevent the compiler from widening, we somehow need to let know the compiler that this object shouldn't be mutated and this can be done using const assertion:

const languages = [
  {
    nicknames: {
      william: 'bill',
      william2: 'bill 2',
    },
  },
  {
    nicknames: {
      alexander: 'sasha',
    },
  },
] as const

However, with the const assertion alone, we lose type-safety and fortunately, this can be fixed by the satisfies operator, introduced in the Typescript 4.9:

const languages = [
  {
    nicknames: {
      william: 'bill',
      william2: 'bill 2',
    },
  },
  {
    nicknames: {
      alexander: 'sasha',
    },
  },
] as const satisfies readonly Language[]

Since const assertion turns arrays into read-only arrays it is crucial to add readonly when using the satisfies operator.

Next, let's introduce some utilities that we going to use:

Prettify - Accepts a type and returns its simplified version for better readability. Transforms interface to type, simplifies intersections:

type Prettify<T> = T extends infer R ? {
  [K in keyof R]: R[K]
} : never

UnionToIntersection - Turns a union to intersection:

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

[ValueOf](type ValueOf = T[keyof T]) - Returns a union of values of all properties of the passed type:

type ValueOf<T> = T[keyof T];

Now, let's write the function's return type. To form the response type we need to use mapped types, where we are going to map through the elements of the array, using the indexed access types. Since in the mapped type the generic parameter will be the element of the array, which is an object, and the object can't be used as a key. Possible types for keys are defined in type PropertyKey = string | number | symbol, thus even though we don't need the keys we will still need to change it to some primitive to make the code word. In our case, the key will be the nicknames themselves and to change the key, we need to use key remapping:

{
    [K in T[number] as keyof K['nicknames']]: K['nicknames'];
}

For the following array:

[
      {
        nicknames: {
          william: 'bill',
          william2: 'bill 2',
        },
      },
      {
        nicknames: {
          alexander: 'sasha',
        },
      },
    ]

The result of the mapped type will look like this:

{
  william: {
      readonly william: "bill";
      readonly william2: "bill 2";
  };
  william2: {
      readonly william: "bill";
      readonly william2: "bill 2";
  };
  alexander: {
      readonly alexander: "sasha";
}

We only need the values and we can use ValueOf for that:

ValueOf<{
    [K in T[number] as keyof K['nicknames']]: K['nicknames'];
}>

This will result in a union:

{
    readonly william: "bill";
    readonly william2: "bill 2";
} | {
    readonly alexander: "sasha";
}

Now we can use the UnionToIntersection to turn it into a single object:

UnionToIntersection<ValueOf<{
    [K in T[number] as keyof K['nicknames']]: K['nicknames'];
}>>

Now the result will be what we want:

{
    readonly william: "bill";
    readonly william2: "bill 2";
    readonly alexander: "sasha";
}

I have actually wrapped the mapped type with the Prettify to get the result in the way that I've shared, without it you would get something like this:

{
    readonly william: "bill";
    readonly william2: "bill 2";
} & {
    readonly alexander: "sasha";
}

Link to playground

Izhaki
  • 23,372
  • 9
  • 69
  • 107
wonderflame
  • 6,759
  • 1
  • 1
  • 17
0

One way to do this is to use TypeScript's type inference and utility types to extract the keys and values from the languages array and combine them into a single type. For example:

type Values<T> = T[keyof T];
type Nicknames<T extends Language> = Values<T["nicknames"]>;
function getNicknames<T extends Language[]>(
  languages: T
): Record<Nicknames<T>, string> {
  const result: Record<Nicknames<T>, string> = {} as any;
  for (const language of languages) {
    for (const [name, nickname] of Object.entries(language.nicknames)) {
      result[nickname as Nicknames<T>] = name;
    }
  }
  return result;
}
Pluto
  • 4,177
  • 1
  • 3
  • 25
  • 2
    Not as simple as that. You can use the [typescript playground](https://www.typescriptlang.org/play) to give a go - there are tons of hurdles in doing this. Once the input is typed, typescript loses it's means to introspect specific properties and just return the types. Without `as const` it merges the types so each item includes the types of other items. – Izhaki Jul 04 '23 at 23:48
  • I understand your frustration. TypeScript's type inference can be tricky sometimes, especially when dealing with arrays of objects. I updated my answer. – Pluto Jul 04 '23 at 23:59
0

It is possible.
We can write a function that takes the languages array and returns a type with the desired structure.
Here's the code snippet.

interface Language {
  nicknames: Record<string, string>;
}

const languages: Language[] = [
  {
    nicknames: {
      william: 'bill',
    },
  },
  {
    nicknames: {
      alexander: 'sasha',
    },
  },
];

type MergedNicknames = {
  [K in keyof Language['nicknames']]: Language['nicknames'][K];
};

function mergeNicknames(languages: Language[]): MergedNicknames {
  const merged: MergedNicknames = {} as MergedNicknames;

  for (const language of languages) {
    const nicknames = language.nicknames;
    Object.assign(merged, nicknames);
  }

  return merged;
}

const mergedNicknames = mergeNicknames(languages);
console.log(mergedNicknames);

You can check the result of this in here.
Hope my answer can help your understanding.

Diego Ammann
  • 463
  • 7
  • Thanks Diego. Doesn't seem to work. The type of `mergedNicknames` is `MergedNicknames` and `mergedNicknames.someKeyThatDoesNotExist` does not yield TS error. – Izhaki Jul 05 '23 at 16:30
  • It works on my side. You can check the result on https://codesandbox.io/s/stackoverflow-typscript-answer-75r3pq?file=/src/index.ts – Diego Ammann Jul 06 '23 at 10:43
  • That's odd. If I change the last line to `console.log(mergedNicknames.john.split("."));`, then typescript doesn't complain about `john` and you get and `Cannot read properties of undefined (reading 'split')` error. Are you seeing something different? – Izhaki Jul 06 '23 at 21:15
  • Yeah because there's no john on the result object. `{ william: 'bill', alexander: 'sasha'}` This is the expected result and there's no `john` in the result. – Diego Ammann Jul 07 '23 at 08:45
0

I will answer the part of your question which is about defining the languages variable in a way that it preserves the literal types (instead of widening them to string), without losing type-safety/autocomplete.

As mentioned by wonderflame, there are two main ways to preserve the literal types:

  • using as const,
  • passing it directly as an argument to a function.

The first one does lose type-safety unless you use satisfies, but it is a bit awkward to have to write as const satisfies readonly Language[] every time. And the second solution does not seem good either if you want to define the languages variable in a different place than where it is used.

But there is actually a pretty elegant solution using a simple helper function:

  1. Define a function
const makeLanguages = <const K extends readonly Language[]>(x: K) => x;
  1. Define the languages variable using this helper function:
const languages = makeLanguages([
  {
    nicknames: {
      william: 'bill',
      william2: 'bill 2',
    },
  },
  {
    nicknames: {
      alexander: 'sasha',
    },
  },
]);

Now you have the best of both worlds, the languages variable has the most specific type that you would get by using as const, and you still get type-safety/autocomplete. And I think that using such a helper function is a lot less awkward than having to write as const satisfies readonly Language[].

Guillaume Brunerie
  • 4,676
  • 3
  • 24
  • 32
  • Thanks! But I'm aware that using a wrapper function will solve this. Yet this is not the solution I'm after. Extensions may have upwards of dozen keys - some need specifics, some don't, and a wrapper function is ultimately not much different that `satisfies`. Further, I find it somewhat smelly that typescript forces you to change your API (there are many examples of frameworks, say RTK, where "to satisfy typescript you need to use this API and not the more sensible one". – Izhaki Jul 11 '23 at 21:49
  • Have you used any other programming language than Javascript/Typescript? Typescript goes to absolutely insane lengths to make sure you have to change your API as little as possible. Things like literal types aren’t even remotely possible in the overwhelming majority of typed languages. Yes, here you may need to change your API by adding one wrapper function somewhere, but this is nothing compared to what you would need to change to implement your API in a different programming language. So I definitely do not see that as smelly, it's just one of the drawbacks of using types. – Guillaume Brunerie Jul 11 '23 at 22:19
  • Good point, and taken. Thanks! – Izhaki Jul 11 '23 at 22:26