TypeScript Tips and Tricks
TypeScript is a superset of JavaScript. It’s like JavaScript, but with superpowers.
Vanilla JavaScript is a dynamically typed language. For example, if you assign a number type to a variable, then assign a string to that same variable further in the code, JavaScript compiles just fine. You’ll only get an error when something breaks in production.
If you’re set against using static typing tools like TypeScript, you can use JavaScript linters to provide some type checks. A linter would have helped catch the error in our example. Linters have their limits, however. For instance, linters don’t support union types — we’ll explore this further in the article — and they also can’t lint complex object structures.
TypeScript provides more type features to JavaScript, enabling you to structure your codebase better. It type-checks your codebase during compile-time and helps prevent errors that may make it to production.
TypeScript improves JavaScript development in many different ways. But in this article, we’ll zoom in on six areas, including helpful tips and tricks for using TypeScript. Some of these tips will discuss the ways TypeScript supports functional programming.
Read-Only Types
Functional programming generally demands immutable variables — and by extension, immutable objects as well. An object with four properties must have the same properties throughout its life, and the values of those properties can’t change at any point.
TypeScript makes this possible with the Readonly utility type. Here’s a type without it:
...
type Product = {
name: string
price: number
} const products: Product[] = [
{
name: "Apple Watch",
price: 400,
},
{
name: "Macbook",
price: 1000,
},
] products.forEach(product => {
// mutating here
product.price = 500
})
...
In the code, we mutated the price property. Since the new value is a number data type, TypeScript doesn’t throw an error. But using Readonly, our code is as follows:
...
const products: Readonly<Product>[] = [
{
name: "Apple Watch",
price: 400,
},
{
name: "Macbook",
price: 1000,
},
] products.forEach(product => {
// mutating here
product.price = 500
})
...
As we can see in this screenshot, the price property is read-only and can’t be assigned another value. Attempting to mutate this object’s value will throw an error.
Union Types
TypeScript allows you to combine types in compelling ways. The combination of two or more types is called a union type. You use the “|” symbol to create a union type. Let’s look at some examples.
Number and String Union Type
Sometimes, you need a variable to be a number. Other times, you need the same variable to be a string.
With TypeScript, you can achieve this by doing something as simple as:
function(id: number | string) {
...
}
The challenge with the union type declared here is you can’t call id.toUpperCase because TypeScript doesn’t know if you’re going to pass a string or a number during the function declaration. So, to use the toUpperCase method, you must check if the id is a string using typeof === “string.”
But, if it can’t apply a standard method to all members that make a particular union, TypeScript won’t complain.
Limiting Acceptable Types
With unions, you can also limit a variable’s acceptable data-type values. You do this using literal types. Here’s an example:
function(type: "picture" | "video") {
...
}
This union type comprises the string literal types of picture and video. This code causes other string values to throw an error.
Discriminated Unions
Another positive thing about unions is that you can have object types of different structures, each with a common distinguishing property. Here’s an example:
...
type AppleFruit = {
color: string;
size: "small" | "large"
} type OrangeFruit = {
isRipe: boolean;
count: number;
} function describeFruit(fruit: AppleFruit | OrangeFruit) {
...
}
...
In this code, we have a union type, fruit, made of two different object types: AppleFruit and OrangeFruit. The two types of fruits have no properties in common. This difference makes it difficult for TypeScript to know what fruit is when we use it, as the following code and screenshot illustrate:
...
function describeFruit(fruit: AppleFruit | OrangeFruit) {
if (fruit.color) {
// throw error...see Figure B.
}
}
...
The error in this screenshot shows that color doesn’t exist on the orange type. There are two solutions to this.
The first solution is to check, in a more acceptable way, if the color property exists. Here’s how:
...
function describeFruit(fruit: AppleFruit | OrangeFruit) {
if ("color" in fruit) {
// now typescript knows fruit is of the apple type
}
}
...
We check if the color property is in the fruit object. Using this check, TypeScript can correctly infer the type, as this screenshot shows:
The second solution is to use discriminated union. This method implies having a property that clearly distinguishes both objects. TypeScript can use that property to know which type is being used at that particular time. Here’s how:
...
type AppleFruit = {
name: "apple";
color: string;
size: "small" | "large";
} type OrangeFruit = {
name: "orange";
isRipe: boolean;
count: number;
}
function describeFruit(fruit: AppleFruit | OrangeFruit) {
if (fruit.name === "apple") {
// apple type detected
}
}
...
Since both types have the name property, fruit.name won’t throw an error. And, using the value of the name property, TypeScript can determine the type of fruit.
Intersection Types
In contrast to union types, which involve type1, type2, _or_ type3, intersection types are type1, type2, and type3.
Another significant difference between these types is that while union types can be strings or numbers, intersection types can’t be strings and numbers. Data can’t be a string and a number at the same time. So, intersection types involve objects.
Now that we’ve discussed the difference between union and intersection types, let’s explore some ways of doing intersections:
...
interface Profile {
name: string;
phone: string;
} interface AuthCreds {
email: string;
password: string;
}
interface User: Profile & AuthCreds
...
Profile and AuthCreds are examples of interface types that exist independently of each other. This independence means you can create an object of type Profile and another of type AuthCreds, and these objects may not be related together. However, you can intersect both types to make a bigger type: User. This type’s structure is an object with four properties: name, phone, email, and password, all of the string type.
Now you can create a User object like this:
...
const user:User = {
name: "user";
phone: "222222",
email: "user@user.com"
password: "***"
}
...
TypeScript Generics
Sometimes, when you create a function, you know its return type. Here’s an example:
...
interface AppleFruit {
size: number
} interface FruitDescription { d
escription: string;
} function describeFruit(fruit: AppleFruit): AppleFruit & FruitDescription {
return {
...fruit,
description: "A fruit",
}
} const fruit: AppleFruit = {
size: 50
}
describeFruit(fruit)
...
In this example, the describeFruit function takes in a fruit parameter of the AppleFruit type. It returns an intersection type made of the AppleFruit and FruitDescription types.
However, what if you wanted this function to return descriptions for different fruit types? Generics are relevant here. Here’s an example:
...
interface AppleFruit {
size: number
} interface OrangeFruit {
isOrangeColor: boolean;
}
interface FruitDescription {
description: string;
} function describeFruit<T>(fruit: T): T & FruitDescription {
return {
...fruit,
description: "A fruit",
}
} const appleFruit: AppleFruit = {
size: 50
} describeFruit(appleFruit) const orangeFruit: OrangeFruit = {
isOrangeColor: true
} describeFruit<OrangeFruit>(orangeFruit)
...
The generic function describeFruit accepts different types. The code determines the type of fruit to pass when it calls the function.
The first time we call describeFruit, TypeScript automatically infers T to be AppleFruit because appleFruit is of that type.
The next time, we specify the T’s type to be OrangeFruit using “OrangeFruit” before calling the function.
These lines do the same thing, but, in some cases, the automatic inference may not be accurate.
We can pass different types to that function in our example, which simply returns an intersection of FruitDescription and the type we passed.
Here’s an example of passing a type to a function using generics:
The describeFruit function has a type, as we originally defined it with OrangeFruit.
Path Aliases with TypeScript
You might usually import like this:
...
import Button from "../../../../components/Button"
...
This command to import may be in different files that require this component. When you change the location of the “Button” file, you also need to change this import line in the various files that use it. This adjustment also results in more file changes to track in version control.
We can improve how we import by using alias paths.
TypeScript uses the “ tsconfig.json” file to store the configurations that enable it to work as you want. Inside, there’s the paths property. This property lets you set path aliases for different directories in your application. Here’s how it looks, using compilerOptions, baseUrl, and paths:
...
{
"compilerOptions": {
"baseUrl": ".", // required if "paths" is specified.
"paths": {
"components/*": ["./src/components/*"] // path is relative to the baseUrl
}
}
}
...
With the components alias, you can now import like this:
...
import Button from "components/Button"
...
Regardless of how deep you are in a directory, using this command correctly resolves the “Button” file.
Now, when you change your component’s location, all you must do is update the paths property. This method means more consistent files and fewer file changes for version control.
Using Libraries with Built-In TypeScript Support
When you use libraries without TypeScript support, you miss TypeScript’s benefits. Even a simple mistake — such as using the wrong data types for an argument or object property — may cause you headaches without the warning TypeScript provides. Without this alert, your app might crash because it expected a string, but you passed a number instead.
Not every library has TypeScript support. When you install a library that does support TypeScript, it installs the distributed code with TypeScript declaration files.
An example of this type of library is ActiveReportsJS, a reporting solution for visualizing data in front-end applications. Libraries like this come with a complete set of TypeScript definitions that enable you to enjoy TypeScript’s static typing.
Conclusion
In this article, we’ve explored how TypeScript improves JavaScript coding. We’ve also discussed how TypeScript supports some functional programming techniques. This ability makes TypeScript suitable for object-oriented programming (OOP) developers and functional programmers.
As a next step, you can dive into TypeScript’s config options. TypeScript provides this exhaustively thorough resource to help you build your next app.
Originally published at https://www.developer.mescius.com on December 3, 2021.