Back

5 Tips To Improve Your TypeScript Code

5 Tips To Improve Your TypeScript Code

TypeScript is rapidly gaining popularity as a way to write type-safer, maintainable JavaScript code. However, while it is relatively easy to start, mastering it requires knowledge of its advanced features; learning TypeScript isn’t always easy. This article will cover five tips that will help you improve your TypeScript code, from using set theory to mastering enums and generics.

The tips in this article will help you utilize TypeScript’s type-checking to its full potential, producing improved code quality for your server and web development tasks. To get the most out of this text and become a great software developer, you must have sufficient knowledge of JavaScript, including variables, data types, and methods. To follow along with code samples provided in this article, you can make use of the official TypeScript playground.

When you are ready, let’s dive in.

1. Types As Sets

In sets theory, a set is a collection of elements distinct from each other and share some common properties.

The knowledge of set theory will help you better infer the behavior of types in TypeScript. A type is a set of possible values that a variable can hold. For example, the type number is a set of all possible numbers.

let x: number; // a set of all possible numbers 
x = 123 // 123 is a possible number
x = 'Hello' // will throw an error because hello is a string, not a number

From the above code, the variable x is a member of the type number because it can hold a number, in this case, 123. However, attempting to assign a string, such as "Hello", triggers an error because x is not a member of the string type. Hence, we can say x is a proper subset of the type number.

Further, for an assignment to succeed if two types are not the same, a type-casting process occurs. Type-casting is a process of overriding a type. This process occurs in two forms: upcasting and downcasting.

During the process of upcasting, a variable of a more specific type is assigned to a variable of a more general type. To better explain this consider the two variables with different types in the code below:

let name: string = 'John';
let nameLiteral: 'Patrick' | 'James' | 'Wood' = 'Patrick';

name = nameLiteral; // possible because of upcasting

The variable name has a type of string, and the variable nameLiteral has a type of three specific strings: Patrick,James, and Wood. The assignment of nameLiteral to name is allowed because the type of nameLiteral is a proper subset of the type of name, which makes it type-safe. In other words, Patrick, James, or Wood are all strings, and since name is of type string, the TypeScript compiler doesn’t see a problem.

However, reversing the assignment will be downcasting, which is normally disallowed. This is because name is a member of a larger set - string than nameLiteral - Patrick | James | Wood.

let name: string = 'John';
let nameLiteral: 'Patrick' | 'James' | 'Wood' = 'Patrick';

nameLiteral = name; // impossible because of downcasting

The concept of set operators, like union, intersection, difference, and complements, can also be used to combine existing sets into new sets to understand type combinations in TypeScript.

However, TypeScript only uses two of these operators as type operators: the | operator for union and the & operator for intersection. However, they behave counter-intuitively when utilizing them to combine types (interfaces).

Consider the code snippet below:

type User = {
   getId(): number;   
   getName(): string;
}

type Admin = {
   getId(): number;
   getUserName(): string;
}

declare function Person(): User | Admin;

const person = Person()

person.getId() // succeeds
person.getName() //fails
person.getUsername() // fails

The | operator in TypeScript, known as a union type operator, can sometimes be misleading for any developer accustomed to thinking of the | operator as a logical OR operator in other contexts, such as boolean logic. Instead, it creates a type that includes the intersection of properties and methods shared by the constituent types.

Hence, the union of User and Admin will return only the properties and methods that are common to both.

On the other hand, thinking of the & operator in terms of logical AND (&&) can also be misleading. In the code snippet below, you see how intersecting two types (interfaces) returns an output of all methods in both types.

type User = {
   getId(): number;   
   getName(): string;
}

type Admin = {
   getId(): number;
   getUserName(): string;
}

declare function Person(): User & Admin;

const person = Person()

person.getId() // succeeds
person.getName() //succeeds
person.getUsername() //succeeds

In summary, the | operator in TypeScript represents a union type and takes the intersection of the types involved. In contrast, the & operator represents an intersection type and combines the features of multiple types. Understanding this distinction is important to avoid misconceptions and accurately model the intended behavior in TypeScript code.

2. Utilize Discriminated Unions As An Alternative To Optional Fields

Imagine you are developing a TypeScript application that interacts with various APIs, and you need to handle different types of responses, such as successful data retrieval or error notifications. For this, you might typically use optional fields or conditional type checks, like in the following example:

interface APIResponse {
 success: true | false;
 data?: number;
 error?: string;
}

function handleAPIResponse(response: APIResponse) {
 return (response.success) ? response.data : response.error
}

Although optional fields may appear convenient, it’s important to note that the aforementioned function could potentially return undefined. This is because both the data and error values can either be undefined or the specific values defined within the interface property. To visualize this concept, please refer to the accompanying image for a clearer understanding.

-

Instead, with discriminated unions we can create a relationship between the two interfaces which provides a more elegant and type-safe solution:

interface SuccessResponse {
 success: true;
 data: number;
}

interface ErrorResponse {
 success: false;
 error: string;
}

interface APIResponse = SuccessResponse | ErrorResponse;

function handleAPIResponse(response: APIResponse) {
 (response.success) ? response.data: response.error
}

With a relationship established with discriminated unions, the function will return specific values defined in the interface property, as seen in the images below:

-

Instead of using optional fields, we can assure type safety, manage variant-specific logic, and improve code readability and maintainability by using discriminated unions.

Session Replay for Developers

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — an open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

3. Use Type Predicates To Narrow Down Types

To narrow down the type of a function’s return value in TypeScript, predicates come in handy. With type predicates, you can define a function that returns a boolean type to check if a value is of a particular type.

Predicates are often defined in the form of variable is type and are used on the return type of functions, like this:

function func(var: any): var is Type {
 return /* boolean expression */
}

In the sample function above, func accepts a parameter var of type any and returns a boolean value. The var is Type expression indicates that its return value is a type predicate.

Now that you understand its syntax, let’s explore how type predicates can be used in practice:

type Fish = {
 swim:() => void
}

type Bird = {
 fly:() => void
}

function isFish( pet: Fish | Bird ): pet is Fish {
 return (pet as Fish).swim !== undefined 
}

In the above example, we defined two types: Fish that has a method swim and Bird that has a method fly.

By indicating the type predicate pet is Fish in the function isFish, we signify to the compiler that if the expression (pet as Fish).swim !== undefined is true, that is, if pet has the method swim, then the value will belong to the Fish type; otherwise it will be classified as the Bird type. This concept is exceptionally handy because it permits the compiler to narrow down the type, guaranteeing that we are solely handling the specific type within the control statement.

By using this construct, we can convey the accurate types to the compiler, enabling it to understand the types we are working with, as seen below:

function getFood(pet: Fish | Bird) {
   if(isFish(pet)) {
       pet // if true, pet is of type Fish
       return 'fish food' 
   }
   else {
       pet //if false, pet is of type Bird
       return 'bird food'
   }
}

By using type predicates, you can provide the compiler with additional information about a variable type that will be used subsequently; this helps the compiler to make precise type inferences. This can also help us as developers to make debugging processes faster.

4. Improve Code Organization With Enums

In TypeScript, Enums are data types that enable you to write a set of named constants. Enums define related values as numbers or strings. Since they allow grouping related values, it is a very useful way to organize our code, which aids in type safety.

To define an enum, use the enum keyword followed by its name, then its named constant in curly braces, like this:

enum Color {
  Red,
  Green,
  Blue
}
// usage
let color = Color.Red; 

By default, the first enum key defaults to 0 unless defined otherwise(it can also be defined as a string or assigned a custom numeric value), then increments by 1 for each member. This is what the above example looks like under the hood:

enum Color {
  Red = 0,
  Green = 1,
  Blue = 2,
}

You can also decide the numeric value of the first member of the enumeration, then other members increment by 1 from that value:

enum Color {
   Red = 1,
   Green,
   Blue
}

The above illustration translates to Red = 1, Green = 2, and Blue = 3.

Enums are particularly useful in cases where you have a finite number of constant values, like the days of the week. Using an enum can provide a more structured and type-safe approach to working with those values:

enum Weekdays {
 Sunday = 1,
 Monday
 Tuesday,
 Wednesday,
 Thursday,
 Friday,
 Saturday
}

To learn more about enums, check out this article.

5. Improve Code Flexibility With Generics

TypeScript’s generics enable the development of reusable, type-safe components that can handle a wide range of data types. By enabling the development of functions, classes, and interfaces that can operate on any data type as long as it complies with specific requirements, they increase the flexibility of programming.

Generics are declared in TypeScript code as <T>, where T stands for a type that has been passed in - you can interpret <T> as a generic of type T as placeholder for a type that will be defined when an instance of the structure is generated, T will act in this situation similarly to how parameters do in functions.

To illustrate a practical example of the above explanation, we’ll declare a function with a generic type T - type T also being the type of the argument and return value as seen below:

function func<T>(value: T):T {
  return value;
}

const result = func('Hello John');

T now has the type Hello John, the exact argument passed to the function call:

-

This also applies to the variable result:

-

However, you may want your code to return a desired type. You can be specific by setting the desired type as the generic type parameter, like this:

function func<T>(value: T): T {
 return value;
}
const result = func<string>('Hello John');

In the above example, type T is now of type string:

-

Generics allow you to build reusable, adaptable components that can handle a variety of data types without compromising type safety or code readability. As a result, you can quickly adapt and expand your components to operate with new data types as necessary, giving your code greater flexibility and maintainability over time. To gain more knowledge on generics, this article is a must-read.

Conclusion

In conclusion, TypeScript is a powerful tool for writing scalable and maintainable code. By incorporating type annotations, union types, type predicates, enums, and generics, you can greatly improve your code’s type safety and flexibility. Type annotations help catch errors early and provide better documentation, while union types and enums make code more readable and self-explanatory. Type predicates and generics add more flexibility to code, allowing you to create reusable and adaptable functions and classes.

These pointers, however, only scratch the surface of TypeScript’s features and advantages. You are urged to learn more about the language and find new ways to improve your code. Because of TypeScript’s robust tooling, tight typing, and support for contemporary JavaScript code features, you can produce better code more quickly.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs and track user frustrations. Get complete visibility into your frontend with OpenReplay, the most advanced open-source session replay tool for developers.

OpenReplay