TypeScript Learning Adventures: A Tale of Love and Hate - Advanced Types
This article delves into the advanced concepts provided by Typescript that can be beneficial in specific situations encountered during project development...
This article delves into the advanced concepts provided by Typescript that can be beneficial in specific situations encountered during project development.
Several intriguing topics will be explored, including intersection types and their usefulness, type guards and their applications, and discriminated unions, which is an interesting subject with a fancy name.
Additionally, we will examine type casting, a valuable feature for informing Typescript about the type of an object when it cannot determine it automatically.
Intersection types
Intersection types enable the consolidation of multiple types into a unified type, resulting in a new type that incorporates the properties and methods of each type.
The notation for intersection types involves the usage of the &
operator.
Let's take a look at this example:
type Shape = {
type: string;
sides: number;
};
type Color = {
name: string;
hexCode: string;
};
type ColoredShape = Shape & Color;
const rectangle: ColoredShape = {
type: "Rectangle",
sides: 4,
name: "red",
hexCode: "df2c14",
};
Here, we have a type Shape
, which is an object type with properties:
type
- description of that shape,sides
- number of sides
Now, we could've created this with an interface but here we are doing it with a type.
There is also another type, Color
type, which consists of:
name
- color name,hexCode
- hexadecimal code for that color,
And finally, there is ColoredShape
, which should be a combination of these two types.
So the new colored shape should be of type ColoredShape
and there we can store an object which must have all properties from both types (Shape
and Color
).
In the example above, this is an excellent red rectangle😁
Now, I will say that intersection types are closely related to interface inheritance.
We could've achieved the same here by using an interface Shape
and then an interface Color
, and then we create a third interface ColoredShape
, which extends Shape
and Color
:
interface Shape {
type: string;
sides: number;
}
interface Color {
name: string;
hexCode: string;
}
interface ColoredShape extends Shape, Color {}
const rectangle: ColoredShape = {
type: "Rectangle",
sides: 4,
name: "red",
hexCode: "df2c14",
};
Indeed, it can be argued that this approach involves slightly more code.
One potential reason for favoring the usage of types in this scenario instead of interfaces is the strong relationship between them, making interfaces a viable alternative.
Nevertheless, it is important to highlight that while intersection types prove particularly valuable when combined with object types, as demonstrated in this example, they can be employed with any types.
type PhoneNumber = string | number;
type PassFail = boolean | number;
type Combined = PhoneNumber & PassFail;
// error: Type 'string' is not assignable to type 'number'
const myNumber1: Combined = "555-123";
// ok
const myNumber2: Combined = 555123;
Suppose we have a type called PhoneNumber
, which can either be a string
or a number
. Additionally, we have a PassFail
type that can be either a number
or a boolean
.
By intersecting PhoneNumber
with PassFail
, we can create a Combined
type. In this example, the Combined
type is determined to be of type number
because it is the only intersection present.
However, if there were more intersections, the Combined
type could also become a union type, representing the intersection of these two union types.
The intersection operator can be applied to any types, generating the intersection of those types.
When dealing with union types, the resulting intersection consists of the common types shared by them. In the case of object types, the intersection simply combines the properties of these objects.
These are intersection types, which can be occasionally beneficial. While they may not be used all the time, there are situations where expressing something more simply or concisely is achievable through the use of intersection types.
Type guards
When working with custom types, sometimes you will get into situations where you get errors because Typescript is confused.
Let's see this example here:
type Measurement = string | number;
function add(first: Measurement, second: Measurement){
return first + second;
}
// error: Operator '+' cannot be applied to types 'Measurement' and 'Measurement'
This throws an error as we have a custom type, TypeScript is not sure if we are working with string
or number
, and of course, it complains.
To fix this, we can add some code:
type Measurement = string | number;
function add(first: Measurement, second: Measurement) {
if (typeof first === "string" || typeof second === "string") {
return first.toString() + second.toString();
}
return first + second;
}
This is a type guard since it enables us to leverage the versatility provided by union types while ensuring the proper execution of our code during runtime.
Frequently, some functions operate on two or three distinct types, making a union type ideal. However, the specific actions taken with the values depend on their types. For instance, in this example, we either concatenate them or perform mathematical addition based on the type.
Now let's check this example with objects:
type Shape = {
type: string;
sides: number;
}
type Color = {
name: string;
hexCode: string;
}
type UnknownThing = Shape | Color;
function printInfo(possibleColor: UnknownThing) {
console.log(`Name is ${possibleColor.name}`);
}
// Property 'name' does not exist on type 'UnknownThing'.
// Property 'name' does not exist on type 'Shape'.
The problem just is TypeScript doesn't allow us to access this property at all as it is not sure if this is a Shape
or Color
type. The Shape
type doesn't have a name, while the Color
type does.
So to fix this we can use the in keyword that's built into JavaScript:
type Shape = {
type: string;
sides: number;
}
type Color = {
name: string;
hexCode: string;
}
type UnknownThing = Shape | Color;
function printInfo(possibleColor: UnknownThing) {
if ("name" in possibleColor) {
console.log(`Name is ${possibleColor.name}`);
}
}
With this type guard, we can verify whether the property name, expressed as a string, exists within the possibleColor
variable.
However, one drawback of this approach is that mistyping the property name will result in it not functioning correctly, so caution is necessary.
The provided JavaScript code enables the verification of the property's existence within possibleColor
. TypeScript detects this check and permits accessing the property within the if condition.
The last variation can you can encounter errors is with class types, so let's check this example:
class Fish {
swim() {
console.log("Fish swimming");
}
}
class Duck {
swim() {
console.log("Duck swimming");
}
walk() {
console.log("Duck walking");
}
}
type Animal = Fish | Duck;
const fish = new Fish();
const duck = new Duck();
function train(animal: Animal) {
animal.swim(); // ok
animal.walk(); // not ok
}
// Property 'walk' does not exist on type 'Animal'.
// Property 'walk' does not exist on type 'Fish'.
To fix this we have 2 options:
- using
in
keyword as explained in the previous example - using
instanceOf
keyword
Let's check the solution with the instanceOf
keyword:
class Fish {
swim() {
console.log("Fish swimming");
}
}
class Duck {
swim() {
console.log("Duck swimming");
}
walk() {
console.log("Duck walking");
}
}
type Animal = Fish | Duck;
const fish = new Fish();
const duck = new Duck();
function train(animal: Animal) {
animal.swim();
if (animal instanceof Duck) {
animal.walk();
}
}
This alternative approach offers a more elegant solution that eliminates the possibility of mistyping the property string when using the in
keyword.
The instanceOf
operator is a standard operator in regular JavaScript, meaning it is not specific to TypeScript. Similar to the typeof
operator, it operates during runtime rather than being exclusive to TypeScript code.
It is crucial to be aware of these situations when dealing with custom types.
Discriminated unions
Now, there exists a distinctive form of a type guard or rather a construct that aids in type guards, known as the discriminated union.
What exactly does that mean?
It refers to a pattern that can be employed when dealing with union types, simplifying the implementation of type guards. This pattern is applicable when working with object types.
Let's take a look at this example:
interface Duck {
walkingSpeed: number;
}
interface Fish {
swimmingSpeed: number;
}
type Animal = Duck | Fish;
function train(animal: Animal){
console.log(`animal speed is: ${animal.swimmingSpeed}`)
}
// Property 'swimmingSpeed' does not exist on type 'Animal'.
// Property 'swimmingSpeed' does not exist on type 'Duck'.
We have a problem here as TypeScript doesn't know if the animal is a fish or a duck and therefore it complains as it is not sure if we can access swimmingSpeed
property.
Now we can do what I explained before. We can check if swimmingSpeed
property exists in animal
with in
keyword. And if that is the case, we can execute this code.
While this approach is viable, the more diverse animals we have, the more checks we need to perform.
Additionally, there is a risk of mistyping the property name in the if
condition, leading to errors.
In this scenario, we cannot utilize the instanceOf
operator since we are working with interfaces. As mentioned earlier, animal instanceOf bird
will not function as expected because interfaces are not compiled to JavaScript and do not have constructor functions available at runtime.
Instead, we can create a discriminated union by assigning a unique property to each interface. Every object should be a part of the union and possess an additional property.
Although any name can be used for this purpose, kind
or type
properties are commonly used:
interface Duck {
type: "duck";
walkingSpeed: number;
}
interface Fish {
type: "fish";
swimmingSpeed: number;
}
type Animal = Duck | Fish;
function train(animal: Animal) {
switch (animal.type) {
case "duck":
console.log(`animal speed is: ${animal.walkingSpeed}`);
break;
case "fish":
console.log(`animal speed is: ${animal.swimmingSpeed}`);
}
}
As you can see, this works like a charm!
It is crucial to note that we are dealing with an interface in this context. Therefore, the value assigned to the type
property is not a variable but rather a literal type, as explained in the previous article.
In this case, the type
property must hold the exact string value duck
. This assignment serves as a discriminated union because every object within our union shares a common property that describes the object itself.
By utilizing this descriptive property in our checks, we achieve 100% type safety, enabling us to determine which properties are available for each object and which are not.
This pattern proves to be valuable when working with objects and union types. Notably, it also functions effectively with interfaces since the interface ensures that any object created based on it must include the specified type
property.
By adopting this approach, we can avoid checking for the existence of a specific property or relying on the instanceOf
operator. Instead, we leverage a known property to determine the type of objects we are working with.
Additionally, this approach mitigates the risk of mistyping, as TypeScript recognizes that the only possible values for the animal type
are duck
and fish
.
It also provides code completion assistance, and any typographical errors result in immediate error detection.
Therefore, this pattern proves highly beneficial when working with objects and union types.
Type casting
In TypeScript, there are situations where it becomes necessary to explicitly specify the type of a value because TypeScript lacks the necessary information.
Type casting allows you, as a developer, to inform TypeScript that a particular value should be treated as a specific type, even when TypeScript is unable to infer it automatically.
An illustrative instance of this situation is when we obtain a reference to an element in the HTML DOM.
Let's examine the following example with this HTML file:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<input type="email" id="user-email">
</body>
</html>
and this is a TypeScript file:
const emailInput = document.getElementById("user-email");
// error Property 'value' does not exist on type 'HTMLElement'
emailInput.value = "blablabla";
In this scenario, we have an HTML document containing an input element for the user's email, and we attempt to access that element.
However, TypeScript raises an error.
As developers, we are aware that we assigned the ID to this paragraph element, but TypeScript lacks this knowledge.
TypeScript does not analyze our HTML files in-depth. Upon hovering over the code, TypeScript identifies the type as either HTMLElement
or null
.
To resolve this issue, we need to inform TypeScript that the selected element is not only not null
but also of type HTMLInputElement
.
This is achieved through type casting.
There are two ways to perform type casting, each with its syntax, and they are functionally equivalent:
- using angled brackets before the variable is converted
- utilizing the
as
keyword.
Let's use the first approach:
const emailInput = <HTMLInputElement>document.getElementById("user-email");
emailInput.value = "blablabla";
This approach works perfectly fine and is completely valid to use.
If you have experience with React, you might be familiar with a similar angled bracket syntax used for writing JSX code in React components within JavaScript or TypeScript files (if TypeScript is used in React projects).
However, if you are not familiar with React, this information might not be relevant to you.
It's important to note that the angled brackets used in React projects serve a different purpose and are not related to passing type information.
Instead, these brackets are parsed by build tools and ultimately by React itself to determine what should be rendered on the screen. This usage is completely separate from TypeScript.
To avoid any potential conflicts with the JSX syntax used in React, the TypeScript team offers an alternative syntax for type casting using the as
keyword, which is the second approach available:
const emailInput = document.getElementById("user-email") as HTMLInputElement;
emailInput.value = "blablabla";
By using the type casting syntax with the as
keyword, we inform TypeScript that the expression preceding it will result in a value of type HTMLInputElement
.
Consequently, no error is raised, and the code functions correctly.
This presents an alternative option, and you can choose to use either of the two syntaxes based on your preference. However, it is crucial to maintain consistency throughout your project and avoid switching between these two syntaxes.
It is advisable to select one approach and adhere to it consistently throughout the whole project.
Conclusion
In conclusion, TypeScript offers a range of advanced type features that empower developers to write more robust and expressive code. We explored several of these features, including intersection types, which allow us to combine multiple types into a single type, providing a comprehensive set of properties and methods.
Type guards proved to be invaluable in situations where we needed to handle multiple types within a union type. By leveraging conditional checks and discriminated unions, we gained greater control over the behavior of our code, ensuring its correctness at runtime.
Type casting emerged as a powerful tool for explicitly specifying the type of a value when TypeScript is unable to infer it automatically. Whether using angled brackets or the as
keyword, type casting enables us to communicate our intentions to TypeScript and achieve the desired type safety.
By harnessing the capabilities of intersection types, type guards, discriminated unions, and type casting, we unlock new possibilities for creating more maintainable and reliable TypeScript code. These advanced type features not only enhance our development experience but also contribute to the overall quality and correctness of our software.
As you continue to explore TypeScript, I encourage you to experiment with these advanced types and incorporate them into your projects. By leveraging these features effectively, you can elevate your TypeScript skills and build more robust and resilient applications.
Happy coding!
Comments ()