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"}
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;
};
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">
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]}
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"]);
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;
}
There are 2 main problems with this:
- The
fields
argument can contain ANY strings here, but we want to narrow that to only allow keys from thePerson
interface. - 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;
}
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[]
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;
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;
}
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"])
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]}
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">
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"]
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]
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]
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];
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];
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;
}
Top comments (6)
Hi Shane,
This post helps me solve me problem, thank you!
Sorry, I have a question about
typeof keys[number]
, do you know how does keys[number] work? is there document or code you know about it?Thanks for this write up @shakyshane. Helped me get unblocked!
Thanks for this awesome article!
Really helped me with a similar case
This is a really good article.
However the last example is a bit weird, I assume that some kind of person-like object would have been passed in to the function as a parameter, and then the function returns a subset of the object. essentially putting a return type to a function that "picks" a subset of the provided object.
I mean in the last example
getPerson
is an actual function, not a type. Or I need more coffee?Great post Shane, thank you! <3
Impressive !!!
High-level post.