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)