6 advanced techniques of TypeScript to help you write clearer code

Dachang Technology Advanced Front-End Node Advanced
Click on the top Programmer Growth Guide, pay attention to the official account
Reply 1, join the advanced Node exchange group

In this article, we’ll introduce six advanced TypeScript tricks, each with examples showing how it’s implemented and used. Using these tips, you can improve not only your code quality, but also your skill level as a TypeScript programmer.

1 – Advanced Types

Using TypeScript’s advanced types, such as mapped types and conditional types, it is possible to build new types based on existing types. By using these types, you can change and manipulate types within a strongly typed system, making your code more flexible and maintainable.

Mapping type

Mapped types iterate over the properties of existing types and apply transformations to create new types. A common use case is to create a read-only version of a type.

type Readonly<T> = {
 readonly [P in keyof T]: T[P];
};
interface Point {
 x: number;
 y: number;
}
type ReadonlyPoint = Readonly<Point>;

In this example, we define a mapping type called Readonly that takes a type T as a generic parameter and makes all its properties read-only. We then created a ReadonlyPoint type based on the Point interface where all properties are read-only.

Condition Type

Conditional types allow you to create new types based on conditions. The syntax is similar to the ternary operator, using the extends keyword as a type constraint.

type NonNullable<T> = T extends null | undefined ? never : T;

In this example, we define a conditional type called NonNullable that takes a type T and checks whether it extends null or undefined. The result type is never if extended, otherwise the original type T.

Let’s extend the advanced type example to add more usability and output.

interface Point {
 x: number;
 y: number;
}
type ReadonlyPoint = Readonly<Point>;

const regularPoint: Point = {
 x: 5,
 y: 10
};

const readonlyPoint: ReadonlyPoint = {
 x: 20,
 y: 30
};

regularPoint.x = 15; // This works as 'x' is mutable in the 'Point' interface
console.log(regularPoint); // Output: { x: 15, y: 10 }
// readonlyPoint.x = 25; // Error: Cannot assign to 'x' because it is a read-only property
console.log(readonlyPoint); // Output: { x: 20, y: 30 }

function movePoint(p: Point, dx: number, dy: number): Point {
 return { x: p.x + dx, y: p.y + dy };
}

const movedRegularPoint = movePoint(regularPoint, 3, 4);
console.log(movedRegularPoint); // Output: { x: 18, y: 14 }
// const movedReadonlyPoint = movePoint(readonlyPoint, 3, 4); // Error: Argument of type 'ReadonlyPoint' is not assignable to parameter of type 'Point'

In this example, we demonstrate the use of the Readonly map type and how it enforces immutability. We create a mutable Point object and a read-only ReadonlyPoint object. We showed that attempting to modify a read-only property results in a compile-time error. We also prevent unintended side effects in your code by stating that read-only types cannot be used where mutable types are expected.

2 – Decorator

Decorators in TypeScript are a powerful feature that allow you to add metadata and modify or extend the behavior of classes, methods, properties, and parameters. They are higher-order functions that can be used to observe, modify, or replace class definitions, method definitions, accessor definitions, property definitions, or parameter definitions.

Class decorator

Class decorators are applied to the constructor of a class and can be used to modify or extend the class definition.

function LogClass(target: Function) {
 console.log(`Class ${target.name} was defined.`);
}
@LogClass
class MyClass {
 constructor() {}
}

In this example, we define a class decorator named LogClass that logs the name of the decorated class when it is defined. We then apply the decorator to the MyClass class using the @ syntax.

Method decorator

Method decorators are applied to methods of a class and can be used to modify or extend method definitions.

function LogMethod(target: any, key: string, descriptor: PropertyDescriptor) {
 console.log(`Method ${key} was called.`);
}
class MyClass {
 @LogMethod
 myMethod() {
 console.log("Inside myMethod.");
 }
}
const instance = new MyClass();
instance.myMethod();

In this example, we define a method decorator called LogMethod that logs the name of the decorated method when the method is called. We then apply the decorator to the myMethod method of the MyClass class using the @ syntax.

Attribute decorator

Property decorators are applied to properties of a class and can be used to modify or extend property definitions.

function DefaultValue(value: any) {
 return (target: any, key: string) => {
 target[key] = value;
 };
}
class MyClass {
 @DefaultValue(42)
 myProperty: number;
}
const instance = new MyClass();
console.log(instance.myProperty); // Output: 42

In this example, we define a property decorator called DefaultValue that sets a default value for the decorated property. We then apply the decorator to the myProperty property of the MyClass class using the @ syntax.

parameter decorator

Parameter decorators are applied to the parameters of a method or constructor and can be used to modify or extend parameter definitions.

function LogParameter(target: any, key: string, parameterIndex: number) {
 console.log(`The parameter ${parameterIndex} of the method ${key} was called.`);
}
class MyClass {
 myMethod(@LogParameter value: number) {
 console.log(`Inside myMethod, use value ${value}.`);
 }
}
const instance = new MyClass();
instance.myMethod(5);

In this example, we define a parameter decorator named LogParameter that logs the index and name of the decorated parameter when the method is called. We then apply the decorator to the value parameter of the myMethod method of the MyClass class using the @ syntax.

3 – Namespaces

In TypeScript, namespaces are a way of organizing and grouping related code. They help you avoid naming conflicts, promote modularity by encapsulating code that belongs together. A namespace can contain classes, interfaces, functions, variables, and other namespaces.

Define namespace

To define a namespace, use the namespace keyword followed by the namespace name. You can then add any relevant code inside the braces.

namespace MyNamespace {
 export class MyClass {
   constructor(public value: number) {}
   displayValue() {
     console.log(`The value is: ${this.value}`);
   }
 }
}

In this example, we define a namespace called MyNamespace and add a class MyClass to it. Note that we use the export keyword to make the class accessible outside the namespace.

Using namespaces

To use code from a namespace, you can import code using the fully qualified name or using namespace imports.

// use fully qualified name
const instance1 = new MyNamespace. MyClass(5);
instance1.displayValue(); // output: The value is: 5
// import using namespace
import MyClass = MyNamespace. MyClass;
const instance2 = new MyClass(10);
instance2.displayValue(); // Output: The value is: 10

In this example, we demonstrate two ways of using the MyClass class in the MyNamespace namespace. First, we use the fully qualified name MyNamespace.MyClass. Second, we import the MyClass class using the namespace import statement and use it with a shorter name.

Nested namespace

Namespaces can be nested to create hierarchies and further organize code.

namespace OuterNamespace {
 export namespace InnerNamespace {
   export class MyClass {
     constructor(public value: number) {}
     displayValue() {
       console.log(`The value is: ${this.value}`);
     }
   }
 }
}
// use fully qualified name
const instance = new OuterNamespace. InnerNamespace. MyClass(15);
instance.displayValue(); // Output: The value is: 15

In this example, we define a nested namespace called InnerNamespace, define a class MyClass inside OuterNamespace, and use The fully qualified name OuterNamespace.InnerNamespace.MyClass uses it.

4 – Mixins

Mixins are a way of combining classes in TypeScript, consisting of multiple smaller parts called mixin classes. They allow you to reuse and share behavior between different classes, promoting modularity and code reusability.

Define mixins

To define a mixin class, create a class that extends the generic type parameter with a constructor signature. This allows mixins to be combined with other classes.

class TimestampMixin<TBase extends new (...args: any[]) => any>(Base: TBase) {
 constructor(...args: any[]) {
 super(...args);
 }
 getTimestamp() {
 return new Date();
 }
}

In this example, we define a mixin called TimestampMixin that adds a getTimestamp method that returns the current date and time. The mixin class extends TBase with a generic type parameter with a constructor signature to allow it to be composed with other classes.

Using mixins

To use a mixin class, define a base class and apply the mixin class to it using the extends keyword.

class MyBaseClass {
 constructor(public value: number) {}
 displayValue() {
 console.log(`The value is: ${this.value}`);
 }
}
class MyMixedClass extends TimestampMixin(MyBaseClass) {
 constructor(value: number) {
 super(value);
 }
}

In this example, we define a base class named MyBaseClass that contains a displayValue method. We then create a new class called MyMixedClass that extends the base class and applies the TimestampMixin mixin to it.

Let’s demonstrate how mixin classes work in practice.

const instance = new MyMixedClass(42);
instance.displayValue(); // Output: The value is: 42
const timestamp = instance. getTimestamp();
console.log(`The timestamp is: ${timestamp}`); // output: The timestamp is: [current date and time]

In this example, we create an instance of the MyMixedClass class that includes the displayValue method of MyBaseClass and the getTimestamp method of the TimestampMixin mixin class. Then, we call these two methods and display their output.

5 – type protection

Type guards in TypeScript are a way of narrowing the scope of the type of a variable or parameter within a particular block of code. They allow you to distinguish between different types, and to access properties or methods specific to those types, promoting type safety and reducing the possibility of runtime errors.

Define type protection

To define a type guard, create a function that takes a variable or parameter and returns a type predicate. A type predicate is a Boolean expression that narrows the type of an argument within the scope of a function.

function isString(value: any): value is string {
 return typeof value === "string";
}

In this example, we define a type guard function isString which checks if a given value is of type string. The function returns a type predicate value is string that narrows the type of the value parameter within the scope of the function.

Using type protection

To use type guards, simply call the type guard function within a conditional statement such as an if statement or a switch statement.

function processValue(value: string | number) {
 if (isString(value)) {
 console.log(`The length of the string is: ${value.length}`);
 } else {
 console.log(`The square of the number is: ${value * value}`);
 }
}

In this example, we define a function called processValue that takes a value of type string | number . We use the isString type guard function to check if a value is a string. If it’s a string, we access the length property specific to the string type. Otherwise, we assume the value is a number and calculate its square.

Let’s demonstrate how type guards work in practice.

processValue("hello"); // output: The length of the string is: 5
processValue(42); // Output: The square of the number is: 1764

In this example, we call the processValue function and pass in a string and a number. The type guard function isString ensures that the appropriate block of code is executed for each type, allowing us to access type-specific properties and methods without raising any type errors.

6 – Utility Types

Utility types in TypeScript provide a convenient way to convert existing types into new types. They allow you to create more complex and flexible types without defining them from scratch, promoting code reusability and type safety.

Using utility types

To use a utility type, use the angle bracket syntax to apply the utility type to an existing type. TypeScript provides various built-in utility types such as Partial, Readonly, Pick, and Omit.

interface Person {
 name: string;
 age: number;
 email: string;
}
type PartialPerson = Partial<Person>;
type ReadonlyPerson = Readonly<Person>;
type NameAndAge = Pick<Person, "name" | "age">;
type WithoutEmail = Omit<Person, "email">;

In this example, we define an interface called Person with three properties: name, age and email . We then created new types based on the Person interface using various built-in utility types.

Let’s demonstrate how these utility types actually work.

Partial

const partialPerson: PartialPerson = {
 name: "John Doe",
};

In this example, we create a partialPerson object of type PartialPerson . The Partial utility type makes all properties of the Person interface optional, allowing us to create partial persons with only the name property.

Readonly

const readonlyPerson: ReadonlyPerson = {
 name: "Jane Doe",
 age: 30,
 email: "[email protected]",
};
// readonlyPerson.age = 31; // Error: Cannot assign to 'age' because it is a read-only property. 

In this example, we create a readonlyPerson object of type ReadonlyPerson . The Readonly utility type makes all properties of the Person interface read-only, preventing us from modifying the age property.

Pick

const nameAndAge: NameAndAge = {
 name: "John Smith",
 age: 25,
};
// nameAndAge.email; // Error: Property 'email' does not exist on type 'Pick<Person, "name" | "age">'. 

In this example, we create a nameAndAge object of type NameAndAge . The Pick utility type creates a new type that contains only the specified properties of the Person interface, in this case name and age.

Omit

const withoutEmail: WithoutEmail = {
 name: "Jane Smith",
 age: 28,
};
// withoutEmail.email; // Error: Property 'email' does not exist on type 'Omit<Person, "email">'. 

In this example, we create a withoutEmail object of type WithoutEmail . Omit uses the Person interface to create a new type from which the specified property is removed, here the email property. This allows us to use the withoutEmail object to represent a Person object without an email property.

const withoutEmail: WithoutEmail = {
 name: "Jane Smith",
 age: 28,
};
// withoutEmail.email; // Error: Property 'email' does not exist on type 'Omit<Person, "email">'

In the above example, we created a withoutEmail object of type WithoutEmail. Omit uses the Person interface to create a new type from which the specified property is removed, here the email property. This allows us to use the withoutEmail object to represent a Person object without an email property.

Summary

In summary, this article explored various advanced TypeScript topics such as namespaces, advanced types, decorators, mixins, type guards, and utility types. By understanding and leveraging these features, you can create more modular, reusable, and maintainable code that conforms to best practices and reduces the likelihood of runtime errors.

By taking advantage of these advanced TypeScript features, you can write cleaner, more organized, and more maintainable code, taking full advantage of TypeScript’s powerful type system and language features.

If you like this article and find it helpful, you can like it and forward it, so that more people can write code clearly!

About this article

Author: simple_lau

https://juejin.cn/post/7225534193995104312


Node community

I have formed a Node.js community with a particularly good atmosphere, and there are many Node.js friends in it. If you are interested in learning Node.js (following plans are also possible), we can conduct Node.js-related activities together. Exchange, learn and build together. Just add a koala friend below and reply “Node”.

f137ac57449a5c332f0c00be80872a4f.png

"Share, like, watch" support a wave