DEV Community

Cover image for Mutually exclusive types done right
Philippe Poulard
Philippe Poulard

Posted on

Mutually exclusive types done right

The difference between types and interfaces in Typescript is not necessarily clear since they look very similar. However, there are some subtle differences and in this article I will focus on a feature that can be achieved only with types : describing mutually exclusive types.

Let's start with an exemple :

type Resource<Type = any> = {
    progress: 'pending' | 'success' | 'error'
    error?: Error
    data?: Type
}
Enter fullscreen mode Exit fullscreen mode

Such structure would be used as follow :

  • set progress to "pending" when accessing the resource
  • then set progress to "success" and the data to data in case of success
  • or set progress to "error" and the error to error in case of failure

However, this is not enforced in any way ; let's try to write some hardcoded values :

// this one is a possible expected value :
const res: Resource = {
    progress: 'pending'
}
Enter fullscreen mode Exit fullscreen mode
// but this one is unexpected, however I can write it !
const res: Resource = {
    progress: 'pending',
    data: 42             // 😡 oh no ! this is allowed
}
Enter fullscreen mode Exit fullscreen mode
// this one doesn't make sense either !
const res: Resource = {
    progress: 'pending',
    data: 42,            // 😡
    error: new Error()   // 😡
}
Enter fullscreen mode Exit fullscreen mode

Union type to the rescue

One strength of types in Typescript is to have the possibility to combine types together. Let's rewrite the type as the union of 3 mutual exclusive types :

type Resource<Type = any> = {
    progress: 'pending'
} | {
    progress: 'success'
    data: Type
} | {
    progress: 'error'
    error: Error
}
Enter fullscreen mode Exit fullscreen mode

And it works fine ! Now if I write :

// I can't write it anymore ! There is an error !
const res: Resource = {
    progress: 'pending',
    data: 42             // πŸ‘ˆ yes ! there is an error
}
Enter fullscreen mode Exit fullscreen mode
// the fix :
const res: Resource = {
    progress: 'success', // πŸ‘Œ
    data: 42
}
Enter fullscreen mode Exit fullscreen mode

So far so good, Typescript now prevent mistakes the way expected. And it also prevent writing the following code, because data might not be a field of res :

const res: Resource = getSomeResource();
doSomething(res.data); // πŸ‘ˆ error !

Enter fullscreen mode Exit fullscreen mode

To access the data, we must use type guards which allow Typescript to refine the real type in an alternative branch of code :

const res: Resource = getSomeResource();
if (res.progress === 'success') {
    doSomething(res.data); // πŸ‘Œ
}
Enter fullscreen mode Exit fullscreen mode

Type guards are certainly one of the most valuable features in Typescript, but unfortunately, they prevent also writing this :

const res: Resource = getSomeResource();
const data = res.data ?? 42; // πŸ˜– oh no ! error !
Enter fullscreen mode Exit fullscreen mode

Why ? Let's say it again: data might not be a field of res !

Mutually exclusive type done right

So let's fix our type in order to have the data field defined in any case :

type Resource<Type = any> = {
    progress: 'pending'
    data?: never
    error?: never
} | {
    progress: 'success'
    data: Type
    error?: never
} | {
    progress: 'error'
    data?: never
    error: Error
}
Enter fullscreen mode Exit fullscreen mode

That way, the data field is an unconditionally part of the result union type ; what changes is that sometimes we can't set it :

// I still can't write that !
const res: Resource = {
    progress: 'pending',
    data: 42             // πŸ‘ˆ yes ! there is an error !
}
Enter fullscreen mode Exit fullscreen mode

...but all the times we can access it :

// but now, I can write that :
const res: Resource = getSomeResource();
const data = res.data ?? 42; // πŸ‘Œ got it !
Enter fullscreen mode Exit fullscreen mode

...and of course type guards are still working the expected way !

As a result, the last type definition is a bit verbose, but the code will be less ! Therefore it will be worth to apply this technique only in certain circumstances.


Thank you for reading, Typescript Padawan !

Top comments (0)