The difference between type
s and interface
s 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 }
Such structure would be used as follow :
- set
progress
to"pending"
when accessing the resource - then set
progress
to"success"
and the data todata
in case of success - or set
progress
to"error"
and the error toerror
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' }
// but this one is unexpected, however I can write it ! const res: Resource = { progress: 'pending', data: 42 // π΅ oh no ! this is allowed }
// this one doesn't make sense either ! const res: Resource = { progress: 'pending', data: 42, // π΅ error: new Error() // π΅ }
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 }
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 }
// the fix : const res: Resource = { progress: 'success', // π data: 42 }
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 !
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); // π }
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 !
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 }
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 ! }
...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 !
...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)