DEV Community

Cover image for Implement a generic oneOf type with Typescript
Maxime
Maxime

Posted on • Updated on

Implement a generic oneOf type with Typescript

Have you ever came across a case in your Typescript codebase where you were trying to describe a oneOf?

I did multiple times and previously, I've had to choose between readability and type safety 😑.

Let say we want to express the fact that a player of our game can choose before an adventure an animal to come with him. Not all the animals that are available in the game though, he'd have to choose only 1 between:

  • a dog 🐶
  • a wolf 🐺
  • an eagle 🦅
  • a mouse 🐭

Readability over type safety

If we care more about readability than type safety, we could simply describe an animal and a player as the following:

interface Animal {
  id: string;
  name: string;

  // the animal can be ONE of the following
  dog: Dog | null;
  wolf: Wolf | null;
  eagle: Eagle | null;
  mouse: Mouse | null;
}

interface Player {
  id: string;
  nickname: string;
  level: number;
  animal: Animal;
}
Enter fullscreen mode Exit fullscreen mode

In this case, the interface would be pretty easy to read, but we could define a dog and a wolf for the player and Typescript would still be happy.

Type safety over readability

We know that the player can only choose between 🐶, 🐺, 🦅 and 🐭. We can take advantage of that information and instead of having a comment // the animal can be ONE of the following, we can express that with Typescript (which will bring us some additional type safety later on!).

To keep things simple, let's start with only 2: 🐶, 🐺.

type Animal = {
  id: string;
  name: string;
} & (
  | {
      dog: Dog | null;
      wolf: null;
    }
  | {
      dog: null;
      wolf: Wolf | null;
    });

interface Player {
  // ... same ...
}
Enter fullscreen mode Exit fullscreen mode

This way we do express the fact that the animal can either be a dog OR a wolf but we can't have both at the same time. While it may seem reasonable to have the type above, let see how it'd look with our 4 animals now:

type Animal = {
  id: string;
  name: string;
} & ({
  dog: Dog | null;
  wolf: null;
  eagle: null;
  mouse: null;
} | {
  dog:  null;
  wolf: Wolf | null;
  eagle: null;
  mouse: null;
} | {
  dog: Dog null;
  wolf: Wolf null;
  eagle: Eagle | null;
  mouse: null;
} | {
  dog: null;
  wolf: null;
  eagle: null;
  mouse: Mouse | null;
});

interface Player {
  // ... same ...
}
Enter fullscreen mode Exit fullscreen mode

Uh 😵... Now can you imagine what it'd look like if we made an update to the game to add 5, 10 or 15 new animals? 🤕

This simply doesn't scale.

Best of both worlds

How cool would it be to have something like the following:

type Animal = {
  id: string;
  name: string;
} & OneOf<{
  dog: Dog;
  wolf: Wolf;
  eagle: Eagle;
  mouse: Mouse;
}>;

interface Player {
  // ... same ...
}
Enter fullscreen mode Exit fullscreen mode

Assuming that we had all the type safety as the example before, I think that'd be pretty nice!

But can we? Let's give it a go, step by step.

The first thing to do would be to have a generic type to which if we pass

{
  dog: Dog;
  wolf: Wolf;
  eagle: Eagle;
  mouse: Mouse;
}
Enter fullscreen mode Exit fullscreen mode

and one key from the type above, let say dog we'd then get the following type:

{
  dog: Dog;
  wolf: Wolf | null;
  eagle: Eagle | null;
  mouse: Mouse | null;
}
Enter fullscreen mode Exit fullscreen mode

We'll call this type OneOnly:

type OneOnly<Obj, Key extends keyof Obj> = { [key in Exclude<keyof Obj, Key>]: null } & Pick<Obj, Key>;
Enter fullscreen mode Exit fullscreen mode

How to draw an owl

Let's try to breakdown and understand the type above:

type OneOnly<Obj, Key extends keyof Obj>
Enter fullscreen mode Exit fullscreen mode

So far, it should be fine. We can pass a type that we call Obj and we then have to pass one key of that type as the second argument.

{ [key in Exclude<keyof Obj, Key>]: null }
Enter fullscreen mode Exit fullscreen mode

Now, we loop over all the keys of the Obj type, except the one we passed as the second argument of the generic. For all of those keys, we say that the only value they can accept is null.

Last missing piece of the puzzle:

Pick<Obj, Key>
Enter fullscreen mode Exit fullscreen mode

We extract from the Obj type the Key.

So in the end we've got

type OneOnly<Obj, Key extends keyof Obj> = { [key in Exclude<keyof Obj, Key>]: null } & Pick<Obj, Key>;
Enter fullscreen mode Exit fullscreen mode

Which if we use it with a dog for example would give us:

type OnlyDog = OneOnly<
  {
    dog: Dog;
    wolf: Wolf | null;
    eagle: Eagle | null;
    mouse: Mouse | null;
  },
  'dog'
>;
Enter fullscreen mode Exit fullscreen mode

And the OnlyDog type would then be equivalent to:

{
  dog: Dog;
  wolf: Wolf | null;
  eagle: Eagle | null;
  mouse: Mouse | null;
}
Enter fullscreen mode Exit fullscreen mode

Cool! I think we've got the most complicated part of our final OneOf type done ✅.

Now, you may have seen this coming... We've got to loop over our Obj type and generate all the OneOnly types for every key:

type OneOfByKey<T> = { [key in keyof T]: OneOnly<T, key> };
Enter fullscreen mode Exit fullscreen mode

Nothing too fancy here, and yet, this line is pretty powerful! If we do:

type OnlyOneAnimal = OneOfByKey<{
  dog: Dog;
  wolf: Wolf;
  eagle: Eagle;
  mouse: Mouse;
}>;
Enter fullscreen mode Exit fullscreen mode

It'll give us a type like the following:

{
  dog: OneOnly<Dog>;
  wolf: OneOnly<Wolf>;
  eagle: OneOnly<Eagle>;
  mouse: OneOnly<Mouse>;
}
Enter fullscreen mode Exit fullscreen mode

That is suuuuuuper coooool right? But... 🤔

We want a union type of all the values from the type above.

Something like:

OneOnly<Dog> | OneOnly<Wolf> | OneOnly<Eagle> | OneOnly<Mouse>
Enter fullscreen mode Exit fullscreen mode

How can we achieve that? Simply with the following:

type ValueOf<Obj> = Obj[keyof Obj];
Enter fullscreen mode Exit fullscreen mode

This will create a union type of all the values from the Obj type.

So finally, our OneOf is now just:

type OneOfType<T> = ValueOf<OneOfByKey<T>>;
Enter fullscreen mode Exit fullscreen mode

🤩 Nice, isn't it? 🤩

Here's the full code as summary:

type ValueOf<Obj> = Obj[keyof Obj];
type OneOnly<Obj, Key extends keyof Obj> = { [key in Exclude<keyof Obj, Key>]: null } & Pick<Obj, Key>;
type OneOfByKey<Obj> = { [key in keyof Obj]: OneOnly<Obj, key> };
export type OneOfType<Obj> = ValueOf<OneOfByKey<Obj>>;
Enter fullscreen mode Exit fullscreen mode

And how we can now use it:

type Animal = {
  id: string;
  name: string;
} & OneOf<{
  dog: Dog;
  wolf: Wolf;
  eagle: Eagle;
  mouse: Mouse;
}>;

interface Player {
  // ... same ...
}
Enter fullscreen mode Exit fullscreen mode

The cool thing now is, when defining a player and its animal, we can only define one of the animals. Not 0, not 2, not 3, not 4. Only one 😁.

Demo one of type safety

Here's a Typescript Playground which you can have fun with. It contains all the code we've seen and the final demo shown in the gif above.

Found a typo?

If you've found a typo, a sentence that could be improved or anything else that should be updated on this blog post, you can access it through a git repository and make a pull request. Instead of posting a comment, please go directly to https://github.com/maxime1992/my-dev.to and open a new pull request with your changes. If you're interested how I manage my dev.to posts through git and CI, read more here.

Follow me

           
Dev Github Twitter Reddit Linkedin Stackoverflow

Latest comments (4)

Collapse
 
mondash profile image
Matthew Ondash

@yoavzibin @maxime1992

I found a workaround/improvement for this which would allow using undefined and missing values for the excluded object properties. Using the typedefs below, both setting the other properties explicitly to undefined and not including them satisfies the type.

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

// This is the only changed type
type OneOnly<Obj, Key extends keyof Obj> = { [key in Exclude<keyof Obj, Key>]+?: undefined } & Pick<Obj, Key>;

type OneOfByKey<Obj> = { [key in keyof Obj]: OneOnly<Obj, key> };
export type OneOfType<Obj> = ValueOf<OneOfByKey<Obj>>;

// Example usage
type AorBorC = OneOfType<{
  a: string;
  b: string;
  c: string;
}>;

// This now works!
const a: AorBorC = {
  a: "a",
  b: undefined,
};

Enter fullscreen mode Exit fullscreen mode

This works by using the +? syntax to make the excluded properties optional.

This would also work with the below version which would allow any nullish value.

type OneOnly<Obj, Key extends keyof Obj> = { [key in Exclude<keyof Obj, Key>]+?: undefined | null } & Pick<Obj, Key>;

Enter fullscreen mode Exit fullscreen mode

Thanks a lot for this article! This was exactly what I needed!!

Collapse
 
yoavzibin profile image
Yoav Zibin

The downside is that to create an animal, you have to put null in all other fields.
E.g.,
const doggy: Animal = {
id: 'id',
name: 'name',
dog: ...,
wolf: null,
eagle: null,
mouse: null
}

Replacing null by undefined doesn't fix it, because you still need to set these other animals to undefined.

If we want to avoid that, and make the fields optional (not null), then this is a simpler solution:

type BaseAnimal = {
id: string;
name: string;
};
interface DogAnimal extends BaseAnimal {
dog: Dog;
}
interface WolfAnimal extends BaseAnimal {
wolf: Wolf;
}
..
type Animal = DogAnimal | WolfAnimal | ...;

Collapse
 
joroshiba profile image
Jordan Oroshiba

The problem with the | is that it allows Animal to have both wolf and dog properties.

oneOf explicitly implies I can have one and only one of those fields. This is "I can have any number of these fields".

Collapse
 
jwp profile image
John Peters

I am now an Owl artist.