Imagine that somebody gives you many unknown objects in black boxes, one by one. You cannot know what is in the box until you ask correct questions. As for an orange, you would ask if it is a fruit, and if it has orange color. And if both answers are true, then probably it is an orange inside. After verifying the object you pass it to the next person in the same black box it was given to you. The next person needs to figure out the object again as there is still no information about it, only the same black box.
This is exactly how functions work with data structures in dynamic type language like JavaScript. Till you put a condition, it can be anything. And even if you ask, the details like - object properties, remain unknown. That is exactly why, in plain JS there are a lot of defensive checks everyplace, as the contract remains unknown, even if some other function checked that before.
Less you know, more you ask
In real life we need to examine the object to understand what can be done with it, we use our human memory and brain specialized in identification of familiar things. Would you grab something into your hand without knowing and seeing what it is? It would be quite risky, as it could be for example a knife.
And the same knowledge demand applies to programming. Broad type, or no type, gives more questions than answers. So if you have many questions, the code need to ask them every time. And asking means - conditions. How you would work with such broad and not framed type:
interface Something {
type: string;
maxSpeed?: number;
cookingTime?: number;
wheelSize?: number;
name?: string;
lastname?: string;
carModel?: string;
age?: number;
...
}
It would be just a nightmare, and even when in the code, you would know, that you currently deal with some car, you can still ask about this car cookingTime
or lastname
:). Above is exact opposite of a good type definition - broad with many optional fields. Another thing is that nobody should ever create such polymorphic structure. And the impact on the code is not neutral, there would be plenty of conditions in every place, and most of these conditions will be done in circumstances where they have no sense.
The real broad type
Let's switch to some real example, I will change the domain into beloved server response structure, with which everyone in some time needs to work. I will assume that our middleware responsible for the communication with the server, models the response in a such way:
interface ServerResponse {
code: number;
content?: Content;
error?: Error;
}
Yes we have it, nice type I could say, better at least from the previous one. But also we know something more, that specific response codes have specific implication on other fields. And exactly these relations are:
- for error codes like - 500 and 400 there is the error field but no content
- for 200 code there is the content but not the error
- for 404 there is no content and no error
The type then, has hidden dependencies and can represent not possible shapes. Hidden dependency exists between property code
and properties content
and error
.
const resp = getResponse()
if (resp.code === 500) {
console.log(resp.content && resp.content.text); // there never can be the content property
}
This condition is a valid question from the type perspective, as the type doesn't say nothing about fields relation, but in reality it cannot happen. Furthermore, even if you know, that there is always the error field, there always needs to be defensive check, as the type just doesn't represent that:
const resp = getRespomse()
if (resp.code === 500) {
console.log(resp.error && resp.error.text); // the error property will be there always
}
The type is too broad
What to do then. You can just write the code and avoid this kind of things by reaching to your own human memory or some kind of documentation, which soon will be outdated. In other words these rules will remain as the tribal knowledge of this project, and ones per a while somebody will ask - why 404 has no error property set, and why somebody checks existing of content
in the error response.
Or instead of that, you can properly model these relations in types. And the good information is - in TypeScript you can nicely do that.
Put the knowledge into the type
Let's try to form the types in the correct, narrow way. For the example purposes I will simplify and say that the server can send only 500, 400, 404 and 200 http codes. Then I can extract below types:
interface SuccessResponse {
code: 200;
content: Content;
}
interface ErrorResponse {
code: 400 | 500;
error: Error;
}
interface NotFoundResponse {
code: 404;
}
Great! Now I have three not related types. But response can be or Success or Error or NotFound. And exactly that I will do, I will join them by union:
type ServerResponse = SuccessResponse | ErrorResponse | NotFoundResponse
And done! Yes that is the whole thing. Now all relations between code and other properties are in the type. There is no way to use content
in ErrorResponse
or error
in SuccessResponse
, or any of them in NotFoundResponse
. If I try to create invalid object, compiler will scream. Also the code
field was narrowed from broad number type into only few specific possibilities.
What's more, after checking of status code
, TypeScript will automatically narrow the type in the scope. So if you check:
if (response.code === 500) {
// here only `error` property is accessible
console.log(response.error.text)
}
if (response.code === 200) {
// here only `content` property is accessible
console.log(response.content.text)
}
if (response.code === 404) {
// here no additional properties are available
}
Moreover these conditions don't need to be used directly. Additional abstraction in form of functions will be far more handy to use:
// declaration of the type guard function
const isErrorResponse = (response: Response): response is ErrorResponse => response.code === 500 || response.code === 400;
// using
if (isErrorResponse(resp)) {
// in this scope resp is type of ErrorResponse
}
More accurate the type, better the code
What I did is narrowing the type, this is exactly what you should do when working with static type language. As types are documentation and the code guide, having them accurate is just in your interest. The pattern I have describe here has a name - it is Discriminated Union or Tagged Union. Check it out in the official TS documentation. See you next time!
Top comments (6)
I cannot understate the importance of the lesson in this article!
The common phrase I hear is "make illegal states unrepresentable" -- every value that is a member of the type should be a valid value (this is exactly the problem in the first version of
Response
since it allows values like{code:32,error:"yes", content:"no"}
or{code:17}
.This makes it so much easier to go back and understand your code, because it means there are no implicit assumptions not present in the type about how values are supposed to be shared.
Not often there will be a benefit.
The readability is a valid point only if the Enum represents something with not readable representation. For http codes I don't think somebody can have any problem, and naming 404 as NotFound really doesn't help.
In TS they are not magic numbers, but rather type-level number literals, and the compiler won't allow you to put anything else in
code
field. So you can put your heart at ease, because this is a totally valid solution. If you know Haskell or Rust, you can see analogy with their type-level symbols.Yes, exactly as Yuriy is saying. I hear very often this argument, that literal union is a magic string/number etc. It is not, as you create a type with few specific number members. There is no way to use different value, it is restricted by type. And also if this particular member will be removed from the allowed set, compiler will show all places where it needs to be changed. Also you need to import Enum every time it is used, for literals you don't need to do that.
I would have no fear for ADT made from literals. It is really ok.
Not a fan of then neither. Seen code with constants definitions for hundreds of lines, where most of them where used only ones in the file. I mean if you have type system, such ways like - literals cane be used without a fear, without type system, yes these things are magic numbers/strings and constants, enums are the only solution.
That was insightful thank you sikora!