2 ways to create a Union from an Array in Typescript

shakyshane

Shane Osbourne

Posted on December 27, 2019

2 ways to create a Union from an Array in Typescript

Let's say we have a simple interface used to describe an object.

interface Person {
    name: string;
    age: number;
    address: string;
};

// This is a valid 'Person' since the object contains all of the
// keys from the interface above and the types allline up.
const person: Person = {name: "shane", age: 21, address: "England"}
Enter fullscreen mode Exit fullscreen mode

Sometimes though, we may need to derive a NEW interface based on the old one, but restricted to certain keys.

We often do this in API responses when limiting the data that's returned.

As a simple example, let's look at how to create a new interface with only the name & age fields from the Person interface.

Deriving a new type

Of course in such a simple example with only 3 fields, we can and probably should just make a new interface like this:

interface Person2 {
    name: string;
    age: number;
};
Enter fullscreen mode Exit fullscreen mode

But as we all know, interfaces get much, much larger in real codebases - so we really want to re-use the name and age fields & their types.

One way to do this would be to pick the fields you want to keep

type Person2 = Pick<Person, "name" | "age">
Enter fullscreen mode Exit fullscreen mode

This gives us exactly what we wanted from above, since Pick uses the elements of the "name" | "age" union to create a new mapped type. It's the same as if we wrote the following manually:

type Person2 = {[K in "name" | "age"]: Person[K]}
Enter fullscreen mode Exit fullscreen mode

The really important part here being that we're deriving a new type, rather than hard-coding a new one. That means subsequent changes to the underlying Person interface will be reflected throughout our codebase.

For example, if the user property changes in Person to be an object rather than a string - that change would propagate through to our new Person2 type.

Mapped types are pretty amazing

As you can probably tell from above, deriving new types from existing ones is an extremely powerful concept - one you'll end up using more and more as you increase your Typescript knowledge.

Now, looking back at the code samples above, what would happen if you wanted to replace the union "name" | "age" with a dynamic array instead?

You can imagine a function that takes an array of keys as strings, and returns an object with only those keys present.

// Here's the goal: we want Typescript
// to know that `p` here is not only a subset of `Person`
// but also it ONLY contains the keys "name" & "age"
const p = getPerson(["name", "age"]);
Enter fullscreen mode Exit fullscreen mode

The first approach might be to accept string[] for the fields, and then return a Partial<Person>

function getPerson(fields: string[]): Partial<Person> {
   // implementation omitted for brevity
   return {} as any;
}
Enter fullscreen mode Exit fullscreen mode

There are 2 main problems with this:

  1. The fields argument can contain ANY strings here, but we want to narrow that to only allow keys from the Person interface.
  2. The return type of Partial<Person> loses type information as it makes all fields optional.

Hard-code it first

To understand how to solve both of those problems, let's rewrite the function signature to include exactly the types we wanted in our example.

function getPerson(fields: ("name" | "age")[]): Pick<Person, "name" | "age"> {
   // implementation omitted for brevity
   return {} as any;
}
Enter fullscreen mode Exit fullscreen mode

Although it's becoming a bit of a token-soup now, looking at the fields argument first you can see that it's not string[] that we want (since that type include EVERY string) it's actually just "an array of keys that exist in person".

This is all about understanding how Typescript can create & consume union types & how to combine them with Array types.

// This is a "union" of 3 string literals
type KeyUnion = ("name" | "age" | "person");

// This is an Array type, where it's elements are 
// restricted to those that are found in the union 
// "name" | "age" | "person"
type KeyArray = ("name" | "age" | "person")[];

// Sometimes it's easier to understand in this style
type KeyArray = Array<"name" | "age" | "person">;

// Finally you can substitute the strings for the
// type alias created above
type Keys = KeyUnion[]
Enter fullscreen mode Exit fullscreen mode

That's a good primer on some basic union/array types - those bits are required to understand why the next bit will work.

Now though, we want to avoid the hard-coded string literals altogether.

It would be extremely tedious to have to maintain a list of strings that refer to keys on an interface and luckily Typescript has plenty of convenience types to help us here.

// Before: hard-coded keys
type KeyUnion = ("name" | "age" | "person");

// After: a 'derived' union type based on 
// the Person interface
type KeyUnion = keyof Person;
Enter fullscreen mode Exit fullscreen mode

Whilst those 2 are equivalent, it's clear which is the best to use. So, applying this piece back to our function, we get the following:

function getPerson(fields: (keyof Person)[]): Pick<Person, keyof Person> {
   // implementation omitted for brevity
   return {} as any;
}
Enter fullscreen mode Exit fullscreen mode

Not there yet.

This DOES provide type safety for the function call site - you won't be able to call getPerson with a key that doesn't exist in the interface any more.

// Type 'string' is not assignable to type '"name" | "age" | "address"'
getPerson(["name", "age", "location"])
Enter fullscreen mode Exit fullscreen mode

Above, TS correctly prevents this, since the 3rd element in the array is not a valid key on Person.

But, the return type is now off.

If we return to mapped types for a moment, we'll easily see why <Pick, keyof Person> is not what we want here.

// this creates a new type with ALL the keys in Person
Pick<Person, keyof Person>

// it's exactly the same as this, it's a 1:1 mapping
{[K in keyof Person]: Person[K]}

// Or, for even more clarity (not valid typescript though), it's like doing this
{[for K in "name" | "age" | "person"]: Person[K]}
Enter fullscreen mode Exit fullscreen mode

So now it's clear, we do still want Pick, but we need to create a union type based on the array of keys passed in - essentially we need the following...

const keys = ["name"]
// -> should produce Pick<Person, "name">

const keys = ["name", "age"]
// -> should produce Pick<Person, "name" | "age">

const keys = ["name", "age", "address"]
// -> should produce Pick<Person, "name" | "age" | "address">
Enter fullscreen mode Exit fullscreen mode

First way to create a union from an array

As a standalone example, providing we give a type assertion like this, we can easily get a union of string literals from the following:

const keys: (keyof Person)[] = ["name", "age"]
Enter fullscreen mode Exit fullscreen mode

By restricting the type with (keyof Person)[] (rather than just string[]) it means that we can ask Typescript nicely to derive a union from this.

// creates the union ("name" | "age")
type Keys = typeof keys[number]
Enter fullscreen mode Exit fullscreen mode

It turns out that using typeof keys[number] on any array will force Typescript to produce a union of all possible types within.

In our case, since we indicated that our array can only contain keys from an interface, Typescript uses this information to give us the union we need.

For clarity, the following would NOT work

const keys = ["name", "age"]

// only creates the union `string` since there's no 
// type assertion after `keys` 
type Keys = typeof keys[number]
Enter fullscreen mode Exit fullscreen mode

Second way to create a union from an array

Union types in Typescript really are the key to unlocking so many advanced use-cases that it's always worth exploring ways to work with them.

To complete our function from above, we'll actually be going with the first example, but it's worth knowing this other trick in case you come across a similar situation.

const assertions

Using a relatively new feature, you can instruct TS to view arrays (and other types) as literal (and readonly) types.

You see, Typescript infers that the keys variable below has the type string[] which is technically correct, but for our use-case, it means that valuable information is lost if we create a union from it.

// to TS, this is just `string[]`...
const keys = ["name", "age"];

// ... which means this union 
// type ends up with just `string`, 
// which includes every single string ever :(
type Keys = typeof keys[number];
Enter fullscreen mode Exit fullscreen mode

But, with a const assertion, we get a very different result. You're basically telling Typescript to not allow widening of any literal types.

So "name" stays as "name", and not string - which means, if we circle back to the previous example, we can now ask Typescript to create a union of all elements.

// now, TS thinks this is `readonly ["name", "age"]`
const keys = ["name", "age"] as const;

// this is now the union we wanted, "name" | "age"
type Keys = typeof keys[number];
Enter fullscreen mode Exit fullscreen mode

Finishing the implementation

Finally, let's put the knowledge to work.

To recap, we wanted to use Typescript to annotate a function such that when we call the function with an array of strings, it validates that the strings all match key names on an interface, and then use those dynamic values to create a narrowed return type derived from the original interface.

// only accept keys from `Person`
// then use them to narrow a new type. 
function getPerson<T extends (keyof Person)[]> (fields: T): Pick<Person, T[number]> {
   // implementation omitted for brevity
   return {} as any;
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
shakyshane
Shane Osbourne

Posted on December 27, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related