6 продвинутых способов создать чистый код на TypeScript

1. Продвинутые типы

Продвинутые типы в TypeScript, такие как сопоставленные (mapped types) и условные (conditional types), позволяют создавать новые типы на основе существующих. Эта особенность помогает эффективно преобразовывать типы и манипулировать ими, повышая гибкость кода и упрощая его сопровождение.

Сопоставленные типы

Сопоставленные типы выполняют перебор свойств существующего типа и применяют преобразование для создания нового типа. Одним из распространенных случаев их использования является создание версии типа read-only.

type Readonly<T> = {
readonly [P in keyof T]: T[P];
};

interface Point {
x: number;
y: number;
}

type ReadonlyPoint = Readonly<Point>;

В этом примере определяем сопоставленный тип Readonly, который принимает тип T в качестве обобщенного параметра и делает все его свойства доступными только для чтения (read-only). Затем создаем тип ReadonlyPoint на основе Point-интерфейса, где все свойства доступны только для чтения.

Условные типы

Условные типы позволяют создать новый тип на основе условия. Синтаксис аналогичен тернарному оператору с использованием ключевого слова extends в качестве ограничения типа.

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

В этом примере определяем условный тип NonNullable, который принимает тип T и проверяет, расширяет ли он null или undefined. Если да, то результирующим типом будет never, в противном случае это будет исходный тип T.

Дополним пример с продвинутыми типами, включив него больше демонстраций юзабилити и выводов.

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; // Это работает, так как 'x' является изменяемым в интерфейсе 'Point'
console.log(regularPoint); // Вывод: { x: 15, y: 10 }

// readonlyPoint.x = 25; // Ошибка: Невозможно присвоить 'x', так как это свойство доступно только для чтения
console.log(readonlyPoint); // Вывод: { 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); // Вывод: { x: 18, y: 14 }

// const movedReadonlyPoint = movePoint(readonlyPoint, 3, 4); // Ошибка: Аргумент типа 'ReadonlyPoint' нельзя присвоить параметру типа 'Point'

Этот пример показывает использование сопоставленного типа Readonly и то, как он обеспечивает неизменяемость. Создаем изменяемый объект Point и объект ReadonlyPoint, доступный только для чтения. Показываем, что попытка изменить свойство read-only приводит к ошибке компиляции. Также демонстрируем, что типы, доступные только для чтения, не могут использоваться там, где ожидается использование изменяемых типов, что предотвращает непреднамеренные побочные эффекты в коде.


2. Декораторы

Декораторы в TypeScript  —  это эффективные функции, позволяющие добавлять метаданные, изменять и расширять поведение классов, методов, свойств и параметров. Это функции высшего порядка, которые можно использовать для наблюдения, изменения и замены определений классов, методов, аксессоров, свойств и параметров.

Декораторы классов

Декораторы классов применяются к конструктору класса и могут быть использованы для изменения и расширения определения класса.

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

@LogClass
class MyClass {
constructor() {}
}

В этом примере определяем декоратор класса под названием LogClass, который логгирует имя декорируемого класса при его определении. Затем применяем декоратор к классу MyClass, используя синтаксис @.

Декораторы методов

Декораторы методов применяются к методу класса и могут быть использованы для изменения и расширения определения метода.

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();

В этом примере определяем декоратор метода под названием LogMethod, который логгирует имя декорированного метода при его вызове. Затем применяем декоратор к методу myMethod класса MyClass, используя синтаксис @.

Декораторы свойств

Декораторы свойств применяются к свойству класса и могут быть использованы для изменения и расширения определения свойства.

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); // Вывод: 42

В этом примере определяем декоратор свойства под названием DefaultValue, который устанавливает значение по умолчанию для декорируемого свойства. Затем применяем декоратор к свойству myProperty класса MyClass, используя синтаксис @.

Декораторы параметров

Декораторы параметров применяются к параметру метода или конструктора и могут быть использованы для изменения и расширения определения параметра.

function LogParameter(target: any, key: string, parameterIndex: number) {
console.log(`Parameter at index ${parameterIndex} of method ${key} was called.`);
}

class MyClass {
myMethod(@LogParameter value: number) {
console.log(`Inside myMethod with value ${value}.`);
}
}

const instance = new MyClass();
instance.myMethod(5);

В этом примере определяем декоратор параметров под названием LogParameter, который логгирует индекс и имя декорированного параметра при вызове метода. Затем применяем декоратор к параметру value метода myMethod класса MyClass, используя синтаксис @.


3. Пространства имен

Пространства имен в TypeScript  —  это способ организации и группировки связанного кода. Они помогают избежать столкновения имен и способствуют модульности, инкапсулируя связанный код. Пространства имен могут содержать классы, интерфейсы, функции, переменные и другие пространства имен.

Определение пространств имен

Чтобы определить пространство имен, используйте ключевое слово namespace, за которым следует имя пространства имен. Затем можно добавить любой связанный код внутри фигурных скобок.

namespace MyNamespace {
export class MyClass {
constructor(public value: number) {}

displayValue() {
console.log(`The value is: ${this.value}`);
}
}
}

В этом примере определяем пространство имен MyNamespace и добавляем в него класс MyClass. Учтите, что ключевое слово export используется, чтобы сделать класс доступным за пределами пространства имен.

Применение пространств имен

Чтобы применить код из пространства имен, можно либо использовать полностью определенное имя, либо импортировать код с помощью импорта пространства имен.

// Использование полностью определенного имени 
const instance1 = new MyNamespace.MyClass(5);
instance1.displayValue(); // Вывод: Значение - 5

// Использование импорта пространства имен
import MyClass = MyNamespace.MyClass;

const instance2 = new MyClass(10);
instance2.displayValue(); // Вывод: Значение - 10

Этот пример демонстрирует два способа использования класса MyClass из пространства имен MyNamespace. В первом случае используем полностью определенное имя MyNamespace.MyClass. Во втором  —  применяем оператор импорта пространства имен, чтобы импортировать класс MyClass и использовать его с более коротким именем.

Вложенные пространства имен

Пространства имен могут быть вложенными для создания иерархии и дальнейшей организации кода.

namespace OuterNamespace {
export namespace InnerNamespace {
export class MyClass {
constructor(public value: number) {}

displayValue() {
console.log(`The value is: ${this.value}`);
}
}
}
}

// Использование полностью определенного имени
const instance = new OuterNamespace.InnerNamespace.MyClass(15);
instance.displayValue(); // Вывод: Значение - 15

В этом примере определяем вложенное пространство имен InnerNamespace внутри OuterNamespace. Затем определяем класс MyClass внутри вложенного пространства имен и используем его с полным именем OuterNamespace.InnerNamespace.MyClass.


4. Миксины

Миксины в TypeScript  —  это способ компоновки классов из нескольких небольших частей, называемых классами-миксинами. Они обеспечивают повторное и совместное использование поведения различных классов, способствуя модульности и повышению уровня повторного использования кода.

Определение миксинов

Чтобы определить класс-миксин, создайте класс, который расширяет параметр обобщенного типа с помощью сигнатуры конструктора. Это позволяет комбинировать класс-миксин с другими классами.

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

getTimestamp() {
return new Date();
}
}

В этом примере определяем класс-миксин под названием TimestampMixin, который добавляет метод getTimestamp, возвращающий текущую дату и время. Класс-миксин расширяет параметр обобщенного типа TBase с помощью сигнатуры конструктора, что позволяет комбинировать его с другими классами.

Использование миксинов

Чтобы использовать класс-миксин, определите базовый класс и примените к нему класс-миксин с помощью ключевого слова extends.

class MyBaseClass {
constructor(public value: number) {}

displayValue() {
console.log(`The value is: ${this.value}`);
}
}

class MyMixedClass extends TimestampMixin(MyBaseClass) {
constructor(value: number) {
super(value);
}
}

В этом примере определяем базовый класс MyBaseClass методом displayValue. Затем создаем новый класс MyMixedClass, который расширяет базовый класс и применяет к нему класс-миксин TimestampMixin.

Теперь продемонстрируем, как класс-миксин работает на практике.

const instance = new MyMixedClass(42);
instance.displayValue(); // Вывод: Значение - 42
const timestamp = instance.getTimestamp();
console.log(`The timestamp is: ${timestamp}`); // Вывод: Временная метка - [текущая дата и время]

В этом примере создаем экземпляр класса MyMixedClass, который включает метод displayValue из класса MyBaseClass и метод getTimestamp из класса-миксина TimestampMixin. Затем вызываем оба метода и отображаем их выводы.


5. Предохранители типов

Предохранители типов в TypeScript  —  это способ сузить тип переменной или параметра в определенном блоке кода. Они позволяют дифференцировать различные типы и получать доступ к свойствам и методам, специфичным для этих типов, обеспечивая безопасность типов и снижая вероятность ошибок во время выполнения.

Определение предохранителей типов

Чтобы определить предохранитель типа, создайте функцию, которая принимает переменную или параметр и возвращает предикат типа. Предикат типа  —  это булево выражение, которое сужает тип параметра в пределах области видимости функции.

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

В этом примере определяем функцию предохранителя типа isString, которая проверяет, является ли заданное значение типом string. Функция возвращает предикат типа value is string, который сужает тип параметра value в пределах области видимости функции.

Использование предохранителей типов

Чтобы использовать предохранитель типа, просто вызовите функцию предохранителя типа в условном операторе, например в if и switch.

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}`);
}
}

В этом примере определяем функцию processValue , которая принимает значение типа string | number. Используем функцию предохранителя типа isString, чтобы проверить, является ли значение строкой. Если это так, обращаемся к свойству length, характерному для string типа. В противном случае принимаем значение за number и вычисляем его квадрат.

Теперь продемонстрируем, как работает предохранитель типа на практике.

processValue("hello"); // Вывод: Длина строки -  5
processValue(42); // Вывод: Квадрат числа равен 1764

В этом примере вызываем функцию processValue как со строкой, так и с числом. Функция предохранителя типа isString обеспечивает выполнение соответствующего блока кода для каждого типа, что позволяет получить доступ к свойствам и методам, специфичным для конкретного типа, без ошибок типа.


6. Утилитные типы

Утилитные типы в TypeScript обеспечивают удобный способ преобразования существующих типов в новые. Они позволяют создавать более сложные и гибкие типы без необходимости определять их с нуля, что способствует повышению уровня повторного использования кода и безопасности типов.

Использование утилитных типов

Чтобы использовать утилитный тип, примените его к существующему типу с помощью синтаксиса угловых скобок. TypeScript предоставляет множество встроенных утилитных типов, таких как Partial, Readonly, Pick и 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">;

В этом примере определяем интерфейс под названием Person с тремя свойствами: name, age и email. Затем используем различные встроенные утилитные типы для создания новых типов на основе интерфейса Person.

Теперь продемонстрируем, как утилитные типы работают на практике.

Partial:

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

В этом примере создаем объект partialPerson типа PartialPerson. Утилитный тип Partial делает все свойства интерфейса Person опциональными, что позволяет создать partial person только со свойством name.

Readonly:

const readonlyPerson: ReadonlyPerson = {
name: "Jane Doe",
age: 30,
email: "[email protected]",
};

// readonlyPerson.age = 31; // Ошибка: Невозможно присвоить 'age', потому что это свойство доступно только для чтения

В этом примере создаем объект readonlyPerson типа ReadonlyPerson. Утилитный тип Readonly делает все свойства интерфейса Person доступными только для чтения, не позволяя изменить свойство age.

Pick:

const nameAndAge: NameAndAge = {
name: "John Smith",
age: 25,
};

// nameAndAge.email; // Ошибка: Свойство 'email' не существует для типа 'Pick<Person, "name" | "age">'

В этом примере создаем объект nameAndAge типа NameAndAge. Утилитный тип Pick создает новый тип, содержащий только указанные свойства интерфейса Person, в данном случае name и age.

Omit:

const withoutEmail: WithoutEmail = {
name: "Jane Smith",
age: 28,
};

// withoutEmail.email; // Error: Property 'email' does not exist on type 'Omit<Person, "email">'

В этом примере создаем объект withoutEmail типа WithoutEmail. Утилитный тип Omit создает новый тип, удаляя указанные свойства из интерфейса Person, в данном случае email.


Мы рассмотрели такие расширенные средства TypeScript, такие как пространства имен, продвинутые типы, декораторы, миксины, предохранители типов и утилитные типы. Понимание и использование этих возможностей позволяет создавать более модульный, многократно используемый и легко сопровождаемый код, который соответствует лучшим практикам и снижает вероятность ошибок во время выполнения.

Используя эти расширенные возможности TypeScript, вы сможете писать более чистый, организованный и легко сопровождаемый код, который в полной мере использует преимущества мощной системы типов и возможностей языка TypeScript.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Marcos Vinicius Gouvea: 6 Advanced TypeScript tricks for Clean Code

Предыдущая статьяЛень писать игру на Rust одному? Позовите на помощь ChatGPT
Следующая статья13 чит-кодов к жизни программиста