DEV Community

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

Posted 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;
}

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 ...
}

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 ...
}

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 ...
}

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;
}

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;
}

We'll call this type OneOnly:

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

How to draw an owl

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

type OneOnly<Obj, Key extends keyof Obj>

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 }

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>

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>;

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'
>;

And the OnlyDog type would then be equivalent to:

{
  dog: Dog;
  wolf: Wolf | null;
  eagle: Eagle | null;
  mouse: Mouse | null;
}

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> };

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;
}>;

It'll give us a type like the following:

{
  dog: OneOnly<Dog>;
  wolf: OneOnly<Wolf>;
  eagle: OneOnly<Eagle>;
  mouse: OneOnly<Mouse>;
}

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>

How can we achieve that? Simply with the following:

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

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>>;

🤩 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>>;

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 ...
}

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.

Top 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.