iTranslated by AI
Further Evolution of Discriminated Unions in TypeScript 4.6
At the time of this article's publication, TypeScript 4.5 Beta has only just been released, but the TypeScript repository is already seeing improvements targeted at TypeScript 4.6. Presumably, because this is a significant new feature, they avoided 4.5, which is already in Beta. In this article, I will introduce one of those improvements: a further evolution of tagged unions. Specifically, it refers to the following PR:
Additionally, with this change, tagged unions will have evolved across three consecutive versions: TypeScript 4.4, 4.5, and 4.6. I will introduce these in this article.
Tagged Unions in TypeScript
Since I have the opportunity, I will also explain the basics of tagged unions in TypeScript. Tagged unions are also known by various other names such as "sum types," but in the English-speaking TypeScript community, the term "discriminated union" seems to be commonly used.
This refers to a special form of union type where each constituent object type can be distinguished at runtime through a property (the tag) they share in common. The most typical approach is to assign a different literal type (especially string literal types) to each constituent as a tag.
type OkResult<T> = {
type: "ok";
payload: T;
};
type ErrorResult = {
type: "error";
payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
if (result.type === "ok") {
return result.payload;
} else {
throw result.payload;
}
}
In the example above, the Result<T> type is a tagged union, and the tag is the type property shared by OkResult<T> and ErrorResult. In the unwrapResult function, type narrowing is performed by checking the tag at runtime with an if statement. In this union type definition, checking the value of the type property clarifies which of the two object types the actual value belongs to. Specifically, if the type property is "ok", result.payload becomes type T. Otherwise, result.payload becomes type Error.
In TypeScript, tagged unions defined this way receive particularly extensive support and are highly valued as a way to represent "or" at the type level.
Tagged Unions and Type Narrowing (Before TS 4.4)
The strength of tagged unions lies in their excellent support for type narrowing. In previous versions of TypeScript, this type narrowing was always performed on variables that held a tagged union type. In the example above, what was narrowed was the variable result (which holds the tagged union type Result<T>). In that example, at the point where it passes the result.type === "ok" check, result is narrowed from Result<T> to OkResult<T>. Consequently, result.payload becomes type T.
The narrowing condition had to explicitly identify the variable being narrowed, specify the tag as a property access on that variable, and compare it using === or similar. This was the situation before TypeScript 4.4.
Evolution in TypeScript 4.4
TypeScript 4.4 introduced support for storing the tag value in a variable beforehand and storing the discriminant expression for the tag in a variable, so that type narrowing is correctly performed even in these cases. For example, the previous example would work in TypeScript 4.4 even if modified as follows:
function unwrapResult<T>(result: Result<T>): T {
const { type } = result;
if (type === "ok") {
// result is correctly narrowed
return result.payload;
} else {
throw result.payload;
}
}
Also, storing the result of === in a variable correctly performs the narrowing as well:
function unwrapResult<T>(result: Result<T>): T {
const isOk = result.type === "ok";
if (isOk) {
// result is correctly narrowed
return result.payload;
} else {
throw result.payload;
}
}
The relevant PR is here:
Evolution in TypeScript 4.5
TypeScript 4.5 added support for cases where the tag is a template literal type. Let's change the example slightly, although it's a bit artificial:
type OkResult<T> = {
type: `ok_${string}`;
payload: T;
};
type ErrorResult = {
type: `error_${string}`;
payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapFooResult<T>(result: Result<T>): T {
if (result.type === "ok_foo") {
// Here result is of type OkResult<T>
return result.payload;
} else {
// Here it remains as Result<T> type
throw result.payload;
}
}
The tag of Result<T> is now a template literal type instead of a fixed string literal type. In this case, the tag of OkResult<T> (the type property) means "any string starting with ok_," and similarly, ErrorResult means "any string starting with error_." Possible values for the type property of Result<T> include strings like "ok_foo", "ok_pikachu", or "error_http".
In the unwrapFooResult function above, we check if the tag is the specific value "ok_foo". From TypeScript 4.5 onwards, by doing this, result is narrowed to the OkResult<T> type inside the if statement (inside the then branch). This is because "ok_foo" is a value that matches the `ok_${string}` type, so result could be an OkResult<T>, while "ok_foo" does not match the `error_${string}` type, eliminating the possibility that result is an ErrorResult.
However, note that in this case, result remains as the Result<T> type in the else branch (it is not narrowed to ErrorResult). This is because there are cases where result.type might be something like ok_pikachu—not ok_foo, yet still belonging to OkResult<T>.
The PR that adds this feature is here:
Evolution in TypeScript 4.6
Now, let's finally explore the evolution in TypeScript 4.6. Previously, we always accessed the core content as result.payload, but in TypeScript 4.6, we can take the TypeScript 4.4 example a step further and perform narrowing in the following way:
type OkResult<T> = {
type: "ok";
payload: T;
};
type ErrorResult = {
type: "error";
payload: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
// payload is T | Error here
const { type, payload } = result;
if (type === "ok") {
// payload is T here
return payload;
} else {
throw payload;
}
}
This overturns the previous common wisdom that type narrowing is only performed on the result object itself; now, a variable like payload that was previously extracted can be narrowed. This is a breakthrough: by performing a check on type, narrowing occurs on a separate variable, payload. Before TypeScript 4.6, payload was fixed as T | Error at the moment of assignment, and checking a different variable afterward would not change its type.
However, for this feature to work, the variable containing the tag (type) and the variable containing the content (payload) must be created through the same destructuring assignment. In other words, narrowing will not occur if you change it as follows:
function unwrapResult<T>(result: Result<T>): T {
const { type } = result;
const { payload } = result;
if (type === "ok") {
// This will not be narrowed!
return payload;
} else {
throw payload;
}
}
Similarly, the following approach will not work either:
function unwrapResult<T>(result: Result<T>): T {
const type = result.type;
const payload = result.payload;
if (type === "ok") {
// This won't be narrowed either!
return payload;
} else {
throw payload;
}
}
There are two main reasons why the variables must be destructured simultaneously. One is an implementation issue: it's easier to track the relationship between variables that way. The other is that since object contents are mutable, properties retrieved from an object at different times cannot be reliably related (or checking whether they can be related would be too costly).
By the way, destructuring function arguments directly, as shown below, is perfectly fine. In fact, the primary demand for this feature may be that it eliminates the need to assign the argument object to a variable first. Before TypeScript 4.6, this was impossible because narrowing always applied to result rather than payload.
function unwrapResult<T>({ type, payload }: Result<T>): T {
// payload is T | Error here
if (type === "ok") {
// payload is T here
return payload;
} else {
throw payload;
}
}
Thus, the new feature in TypeScript 4.6 is characterized by the fact that a variable (payload) containing a property retrieved beforehand from a target object (an object with a tagged union type) is narrowed after the fact by checking another variable (type) derived from the same destructuring assignment.
I mentioned the relevant PR at the beginning of the article, but I'll list it again here:
What still cannot be done in TypeScript 4.6
However, this does not mean that every case can now be resolved with destructuring. The tagged unions we have dealt with so far had a specific characteristic: in both the OkResult<T> and ErrorResult cases, the property representing the content had the common name payload.
Personally, I prefer using distinct names, like this:
type OkResult<T> = {
type: "ok";
value: T;
};
type ErrorResult = {
type: "error";
error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
if (result.type === "ok") {
return result.value;
} else {
throw result.error;
}
}
In this example, the property that was previously payload is named value in OkResult<T> and error in ErrorResult. Since narrowing is performed using the traditional method in the example above, accessing result.value or result.error after narrowing works without issues. In the then branch of the if statement, result is narrowed to OkResult<T>, and this type possesses the value property.
However, such tagged unions cannot benefit from the improvements in TypeScript 4.6. Attempting the following results in a compilation error:
type OkResult<T> = {
type: "ok";
value: T;
};
type ErrorResult = {
type: "error";
error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
const { type, value, error } = result;
if (type === "ok") {
return value;
} else {
throw error;
}
}
The compilation error is as follows, stating that value and error cannot be retrieved from result before narrowing:
error TS2339: Property 'value' does not exist on type 'Result<T>'.
12 const { type, value, error } = result;
~~~~~
error TS2339: Property 'error' does not exist on type 'Result<T>'.
12 const { type, value, error } = result;
~~~~~
In other words, at the Result<T> stage, it cannot be guaranteed that value or error exists in the object. Since accessing non-existent properties is a typical mistake, it is prevented by a compilation error.
Because of this, depending on one's design, there might not be many cases that can immediately benefit from the new features in TypeScript 4.6.
Looking ahead, there are three possible directions. One is to standardize the property names (like payload) for each constituent of the tagged union so they can benefit from TypeScript 4.6. However, personally, I am not a fan of this direction. If property names are standardized across all cases, they would have to be abstract names like payload, and I feel the naming isn't very good.
The second is to make them common property names by adding ?: undefined to the property definitions, as follows:
type OkResult<T> = {
type: "ok";
value: T;
error?: undefined;
};
type ErrorResult = {
type: "error";
value?: undefined;
error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
const { type, value, error } = result;
if (type === "ok") {
return value;
} else {
throw error;
}
}
This works correctly. While it's the best method currently available (in TS 4.6), I don't think it's perfect. This is because it becomes possible to write logic without relying on type at all, as shown below. While this might seem like a good thing at first glance, in my experience, it can become a breeding ground for logic that ignores the intent of the tagged union, so I don't really recommend it. I want people to write branching logic that properly checks the type.
function unwrapResult<T>(result: Result<T>): T {
const { value, error } = result;
if (value !== undefined) {
return value;
} else {
throw error;
}
}
The last one—and this is just my speculation—is to temporarily allow access to properties that might not exist, but treat it as a compilation error if you try to use them before proper narrowing has occurred.
type OkResult<T> = {
type: "ok";
value: T;
};
type ErrorResult = {
type: "error";
error: Error;
};
type Result<T> = OkResult<T> | ErrorResult;
function unwrapResult<T>(result: Result<T>): T {
// This would not be an error (speculation)
const { type, value, error } = result;
// Trying to use value here would be a compilation error (speculation)
console.log(value);
if (type === "ok") {
// Using value here is OK (speculation)
return value;
} else {
throw error;
}
}
Personally, I would be happy if things went in this direction. The trend from TypeScript 4.4 to 4.6 suggests a direction toward supporting destructuring for tagged unions, so the above could be considered an extension of that. Also, the concept of "a variable that results in an error if accessed before an appropriate operation" already exists in TypeScript. It's the variable declared with let.
let foo: number;
// Error if you try to use foo here (since it hasn't been assigned)
console.log(foo);
foo = 0;
// OK to use foo here
console.log(foo);
I'm speculating that if this same mechanism is used, it might not be impossible to have "variables that result in an error if accessed before proper narrowing." Since I hadn't seen this discussed before, I've opened an issue for it. If you think it's a good idea, please support it with a 👍.
Summary
In this article, I have explained the continuous (or planned) enhancements to tagged union support in TypeScript 4.4 through 4.6. Tagged unions are one of the most powerful design patterns available in TypeScript, and it's very exciting to see these ongoing improvements.
Discussion