Pull to refresh

Runtime Type Safety in Typescript (Возможна ли удобная проверка типов в рантайме)

Reading time 12 min
Views 14K

Относительная простота и доступность Javascript, относительная легкость входа в него предопределила на многие годы вперед его популярность. Некоторые языки программирования существенно повлияли на развитие всей отрасли в целом. Так, например SmallTalk стал неким прадедушкой ООП и объектного подхода, хоть сам по себе мало где используется. А Javascript… на мой взгляд это язык, о потенциале которого создатели не имели должного представления. Но мне всегда хотелось работать с чем-то на стыке между гибкостью  Javascript и строгостью типизированных языков. Возможно ли это?


В добрых традициях хабра я хочу сразу оговориться, что не претендую на применимость в 100% случаев, и не считаю мое мнение единственно верным. Приведенное ниже — мой взгляд и отношение к программированию вообще и к написанию кода на TS/JS в частности. Кроме статьи, я этот взгляд постарался донести на митапе, запись которого доступна тут.


Javascript впервые увидел свет к конце 1995, он был встроен в Netscape Navigator. Сам браузер никто уже не помнит, но язык прочно прижился. Интересно то, что изначально Javascript был предназначен для относительно простых вещей, чтобы что-то интерактивное сделать на веб странице. Например анимацию, или обработку нажатий кнопок.


Дело в том, что веб в 90-х был слишком статичным, не хватало анимации и динамики, интерактивности. Основатель Netscape считал, что нужен новый скриптовый язык, способный работать с DOM. При этом — язык не должен предназначаться для разработчиков или инженеров. Для этого уже была Java. Напротив, новый язык должен был предназначаться для дизайнеров.


Согласно исследованию Stackoverflow, JS используют 67% разработчиков. Чтобы мы ни говорили о языке, это что-то да значит.



Несмотря на то, что JS был рожден в спешке, в него были заложены мощные особенности, которые определили его как язык и позволили ему перерасти свои границы и роли, несмотря на его причуды. Javascript до сих пор несет с собой многие особенности, заложенные в него изначально, которые с одной стороны определяют его пресловутую гибкость, но с другой усложняют жизнь разработчикам. Все же знают шутку про две книги о Javascript по 9 долларов за 99 в сумме? Это обусловлено тем, что объекты в Javascript, ведущие себя совершенно идентично, могут быть различных типов.


В дальнейшем была долгая эволюция, появились стандарты ECMA Script, новые особенности языка. Так например появились ключевые слова let и const, промисы, классы как некая абстракция над прототипами и прочее. Если оглянуться вокруг — почти все что мы используем каждый день написано на JS, написано кровью и потом программистов и тестировщиков. Даже на бекенде — появление NodeJS во многом определило роль Javascript как full-stack языка.


Я обо всем этом рассказываю, так как для меня лично это большая боль. Будучи в программировании лет 12 мне было очень трудно после многих лет типизированного C# написать хоть что-то осмысленное на JS. Более того, я могу честно сказать, что так и не смог этого сделать!


Почему? Дело в том, в моей строго типизированной голове просто не укладывается как это можно без компиляции что-то сделать, да еще потом присвоить объекту какое-то новое свойство и отдать все этого в продакшен. Не было какой-то банальной точки минимального контроля.


Было бы здорово соединить полезные свойства гибкого JS и строгого языка, типа C#, причем чтобы эти свойства были всегда, а не только пока пишем код.


О слабой и сильной типизации языков программирования


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



JS язык слабо-типизированный, в то время как TS, оставаясь динамическим, уже имеет строгую типизацию. Это помогает предотвратить большое количество ошибок, опечаток и прочего еще до первого запуска. Вы скажаете, что сейчас столько классных IDE! О каких опечатках я тут рассуждаю. И будете правы. Но IDE не дает гарантии того, что в спешке вы проверите все те подчеркивания, которые среда разработки показывает.


Typescript мне помог в этом. Будучи чем-то среднем между JS и тем же C# (конечно, их же придумал один и тот же человек — Андерс Хейлсберг). Интересно еще то, что он же придумал Turbo Pascal и Deplhi — которые в свое время были буквально промышленными стандартами.


TS дает нам компиляцию, дает типы! Аллилуйя! Если вы теперь накосячите с именем переменной то узнаете об этом сразу, а не через неделю от разгневанного заказчика. Это же великое благо! Именно с TS началась моя реальная работа как веб программиста. Но Typescript не решает фундаментальных проблем, присущих JS и проявляющихся во всей красе во время исполнения кода. Язык не был бы языком программирования, если бы на нем нельзя было ничего сломать. У TS есть свои проторенные дорожки в старую добрую гибкость JS. я уже неоднократно порываюсь подобный слайд убрать из каждой своей презентации, но вижу как часто везде используют ANY. Поэтому поговорим о нем минутку.


Typescript и ANY


TS позволяет писать код с типами, функции с типами входных и выходных данных, и даже делать обобщенные типы. При этом все еще сохраняется лазейка обратно. Любой явно неуказанный тип — это ANY тип. Я часто думаю, что про ANY (еще про unknown) уже достаточно сказано, но нет. Во многих проектах пруд пруди всяческих ANY там, где их использование не обусловлено необходимостью.


Поэтому приведу простой пример.


public ngOnInit(): void {
   const movie = {
       name: 'Game of Thrones',
   };
   this.showToast(movie);
}

private showToast(item: any): void {
   this.toaster.show(`Today's movie is: ${item.name}`);
}

Тут все в порядке, все работает. Но вот мы делаем рефакторинг и решаем, что слово name плохо передает смысл названия фильма, а вот слово title — гораздо лучше. После изменения все продолжит работать и компилироваться на TS, ведь наша функция ожидает ANY на вход. И только где-то на UI мы увидим слово undefined. В поиске этого бага можно потратить много бесценных часов.


Если же отказаться от ANY и написать функцию с нормальным типом на входе:


interface Movie {
   title: string;
}

public ngOnInit(): void {
   const movie: Movie = {
       name: 'Game of Thrones',
   };
   this.showToast(movie);
}

private showToast(item: Movie): void {
   this.toaster.show(`Today's movie is: ${item.name}`);
}

То в случае частичного рефакторинга мы сразу увидим ошибку компиляции.


TS2339: Property 'name' does not exist on type 'Movie'.


Кстати, если вы себе вдруг не доверяете, можно жестко включить no-any правило в линтере (больше информации тут).


Компиляция Typescript и типы данных в рантайме


Все бы хорошо, и мы уже почти подошли к сути, вот только после компилирования TS получается что? Чистейший JS, который и выполняется в браузере или другом движке. А это значит, что все наши типы и проверки было лишь локально в процессе разработки


Вообще, статический анализ кода позволяет избежать громадного количества ошибок, даже в сильно типизированных языках. Я как-то смотрел видео с конференции dotNEXT, в котором выступающий утверждал, что статический анализатор кода их компании помогает избежать 40% ошибок в C# коде еще до запуска программы! Вдумайтесь только, они же статистику собирали по проектам.


Давайте посмотрим, как TS выглядит в деле. Для простоты примера возьмем простую функцию:


export class SampleService {
    public multiply(num: number, num2: number): number {
        return  num * num2;
    }
}

В TS у нас есть класс, публичная функция в этом классе, везде проставлены типы. если мы что-то сделаем не так пока пишем код, то компилятор TS выдаст ошибку. Например такую:



Давайте скомпилируем код функции в Javascript командой tsc в директории проекта. Полученный результат выглядит следующим образом:


"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class SampleService {
    multiply(num, num2) {
        return num * num2;
    }
}
exports.SampleService = SampleService;

Легко заметить, что никаких типов у нас уже нет, в рантайме будет сразу попытка выполнить операцию. В зависимости от реальных аргументов, события могут развиваться по разному:


const arg1 = 2;
const arg2 = 3;
const result1 = service.multiply(arg1, arg2);
// result = 6

const arg1 = 2;
const arg2 = '3';
const result1 = service.multiply(arg1, arg2);
// result = 6, несмотря на разницу в типах, JS сможет провести операцию умножения. В JSON все в виде строк в любом случае

const arg1 = 2;
const arg2 = 'foo';
const result1 = service.multiply(arg1, arg2);
// result = NaN

Результат NaN будет для всех случаев, когда JS не знает что делать в математической операции. Если же мы имеем дело с различного рода объектами, и функция получит объект, все равно будет попытка работы с полями объекта. Соответственно мы рано или поздно получим плохо отлавливаемый undefined.


Для чего может понадобиться проверка типов


Ну хорошо, в принципе ничего неожиданного. JS гибок как никто другой. Все приведенное выше логично и ожидаемо от JS. Работая с TS мы все равно делаем шаг вперед и имеем по крайней мере статический анализ на такого рода ошибки. То есть в идеальном случае, когда мы контролируем 100% кода и тестируем 100% сценариев, проблем быть не должно.


Где же вообще мы можем натолкнуться на ситуацию, когда передаваемый тип отличается от ожидаемого, если TS компилятор все за нас проверил?


Слегка надуманные кейсы:


  • Фронтенд — Получение данных сервера. При получении ответа от сервера, тело ответа HTTP запроса, мы либо получаем any, либо все таки свой тип, но процесса приведения типов или чего-то похожего все равно нет. Если ожидаем объект типа User, а пришел вдруг объект Car, мы узнаем лишь когда произойдет попытка обратиться к какому-то несуществующему полю.
  • Сервер — получение запроса клиента. Тут мы получаем JSON, причем от потенциально неизвестного клиента, и даже если на сервере TS (скажем это NestJS), мы все равно в рантайме имеем то, что получили от клиента.
  • Взлом клиентского кода. Это может быть слегка надумано, но в целом реально. Клиентский JS находится под полным контролем пользователя. Если он конечно сможет в нем разобраться.

Более реальные случаи:


  • ANY в коде своего проекта ANY тоже может встречаться, и тем самым обходить статический анализ TS.
  • Библиотека. Наш код кто-то вызвал извне, никакого контроля.
  • Доступ к полям объекта по имени (obj['prop'])
  • Чистый Javascript в коде Typescript'а

Истина в простоте (и отсутствии неожиданного поведения)


Я задумался над этим по нескольким причинам.


Во-первых, я пришел из C++ и .NET, и мне чрезвычайно близка идея строгой типизации. Что впрочем не мешает мне работать с TS уже несколько лет. Его (и JS конечно же) бенефиты в виде бесшовной работы с JSON покрывают многие проблемы, а скорость работы NodeJS сейчас позволяет делать на нем многое.


Во-вторых, у меня на проекте есть необходимость проверки такого рода, мы делаем постепенную миграцию старого не очень хорошо написанного кода из смеси JS / TS на более структурированный чистый TS. И какое-то время классы могут использоваться как старым кодом, так и новым. Нужна удобная проверка на ошибки, но так, чтобы не писать instance of каждый раз.
Эти проблемы вполне успешно решаются валидацией различного рода. Например, в случае сервера, мы можем добавлять middleware или что-то подобное и выполнять код, валидирующий все нужные запросы. Чуть дальше я приведу примеры. Сложные случаи с бизнес логикой как правило так и решаются.


Но мне не хватет чего-то простого и очевидного. Например:


public multiplyCustomChecked(num: number, num2: number): number {
        if (typeof num !== 'number') {
            throw Error(`Incorrect argument 1 type: ${typeof num}`)
        }
        if (typeof num2 !== 'number') {
            throw Error(`Incorrect argument 2 type: ${typeof num2}`)
        }
        return  num * num2;
    }

Если вызвать некорректно будет следующее:


const arg1 = 2;
const arg2 = 'foo';
const result1 = service.multiplyCustomChecked(arg1, arg2);
// result = Error: Argument 2 of function multiplyCustomChecked has incorrect type: string

const arg1 = 2;
const arg2 = { name: 'test' };
const result1 = service.multiplyCustomChecked(arg1, arg2);
// result = Error: Argument 2 of function multiplyCustomChecked has incorrect type: object

Декоратор для валидации типов


Естественно писать так на каждую фукнцию, да еще и поддерживать в дальнейшем, весьма сомнительная простота. Поэтому давайте немного оптимизируем этот код с точки зрения масштабируемости. А именно напишем декоратор.


import 'reflect-metadata';
export function Typed() {

  return (target: Object, propertyName: string, 
          descriptor: TypedPropertyDescriptor<Function>) => {

    const method = descriptor.value;
    descriptor.value = function() {

      checkTypes(target, propertyName, arguments);

      return method.apply(this, arguments);
    };

  };
}

А функция проверки может выглядит таким образом:


function checkTypes(
  target: Object, propertyName: string, args: any[]): void {

    const paramTypes = Reflect.getMetadata(
      'design:paramtypes', target, propertyName);

    paramTypes.forEach((param, index) => {
      const actualType = typeof arguments[index];
      const expectedType = 
        param instanceof Function ? typeof param() : param.name;
      if (actualType !== expectedType) {

        throw new Error(`Argument: ${index} of function ${propertyName} 
                         has type: ${actualType} different from 
                         expected type: ${expectedType}.`);
      }
  });
}

Вызывать его можно довольно удобно, просто вешать декоратор на нужную функцию:


@Typed()
public multiplyChecked(num: number, num2: number): number {
    return  num * num2;
}

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


Это конечно базовая версия, написанная для демонстрации идеи, она не будет работать в 100% случаев, но передает суть идеи.


Внимательный и знающий Typescript читатель заметит еще один недостаток. Никакой декоратор в текущей версии TS нельзя применить к функции, объявленной за пределами класса. Это ограничения языка на данный момент.

Чуть глубже про декораторы


Рассмотрим чуть детальнее код декоратора. Как вы знаете — декоратор, это всего лишь определенного вида функция. На вход она получает:


  • target: Object — это тот объект, у которого функция вызвана. Инстанс класса.
  • propertyName: string — название функци.
  • descriptor: TypedPropertyDescriptor<Function> — некий дескриптор, через который мы можем обратиться к исходной функции

По сути план действий тут прост:


  • сохранить исходную функцию — descriptor.value,
  • сделать нужные нам проверки и действия
  • вызвать исходную функцию через method.apply(this, arguments), где method — это сохраненная ссылка из descriptor.value

Сами проверки становятся возможны за счет Typescript и метадаты.


Вызов Reflect.getMetadata('design:paramtypes', target, propertyName) возвращает нам список параметров исходной функции, а текущие аргументы мы можем получить классическим для JS способом — через переменную arguments.


Стоит только отметить один момент, каждый из параметров в возвращаемом значении Reflect.getMetadata будет по сути функцией, поэтому чтобы узнать сам ожидаемый тип, можно либо проверить поле name, либол эту функцию вызвать, и только после этого использовать typeof.


Разумеется, это решение можно и нужно развивать до того состояния, когда ваша задача будет выполнена. Но уже в таком простом виде можно смело использовать. Этакая симуляция строгой типизации в JS / TS.


Еще одно необходимое замечание, для включения декораторов и метаданных необходимо в tsconfig включить следующие настройки: "emitDecoratorMetadata": true, "experimentalDecorators": true

Скорость работы


Возникает закономерный вопрос о производительности. Давайте сравним чистый вызов с умножением пары чисел, и такой же, но обернутый декоратором. Замеры производились на коленке с помощью console.time и console.timeEnd


Чистый вызов функции в среднем: 0.08ms


Вызов функции с декоратором в среднем: 0.3ms.


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


Само по себе не критично, хотя и достаточно много. По сути условные 0.22 ms отнимается проверкой на каждый вызов. Часть из этого отнимается на получение метадаты, но если сравнить — то это время даже меньше чем время выполнения умножения (если брать цифры второго и последующих запусков). Довольно много занимает forEach, если переписать на обычный for, картина станет приятнее.


Сами по себе цифры не показательны, но некий тренд увидеть можно — минимальное влияние метадаты и то, что лучше использовать простые конструкции типа for там, где критична скорость.
.


Чуть больше деталей и цифр

First Run


part foreach for
clean_func 0.113ms 0.080ms
metadata 0.208ms 0.148ms
typeof 0.063ms 0.045ms
foreach 0.303ms 0.163ms
checked_func 0.785ms 0.515ms

Second Run


part foreach for
clean_func 0.113ms 0.080ms
metadata 0.012ms 0.031ms
typeof 0.002ms 0.001ms
foreach 0.066ms 0.040ms
checked_func 0.140ms 0.221ms

Готовая реализация


Я подготовил небольшой npm пакет, который содержит первую версию декоратора @Typed, его можно скачать и попробовать в деле — https://www.npmjs.com/package/ts-stronger-types.


Я буду признателен, за реальную обратную связь в виде issue на github.


Прочие решения для валидации в рантайме


Для полноты картины стоит затронуть существующие решения, позволяющие делать простую или продвинутую валидацию.


IO-TS Runtime type system for IO decoding/encoding


Данная библиотека построена на принципе преобразования объектов, чем-то больше напоминает Модели Данные в некой ORM, нежели обычную валидацию. Предполагается, что классы вы описываете в следующем виде:


import * as t from 'io-ts'

const User = t.type({
  userId: t.number,
  name: t.string
})

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


Superstruct-TS Typescript customer transformer for json validation based on type


Эта интересная библиотека взяла за основу другой подход. Типы остаются обычными TS типами, но у нас есть возможность вызова функции проверки:


import { validate } from "superstruct-ts-transformer";

type User = {
  name: string;
  alive: boolean;
};

const obj = validate<User>(JSON.parse('{ "name": "Me", "alive": true }'));

Возможно это один из наиболее дешевых способов проверить ответ с сервера (или запрос клиента) на типовую валидность. Но есть и ограничения:


  • нельзя использовать обычный tsc компилятор, только ttypescript
  • по сути это только проверка JSON

Class-Validator Validation made easy using TypeScript decorators


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


export class Post {
    @IsString()
    title: string;

    @IsInt()
    @Min(0)
    @Max(10)
    rating: number;

    @IsEmail()
    email: string;

    @IsDate()
    createDate: Date;
}

А далее в любой нужный момент вы можете вызвать функцию validate из этой библиотеки для проверки, функция вернет Promise. Соответственно на catch можно увидеть валидационную ошибку. В репозитории есть подробные примеры.


Эта библиотека интересна еще и тем, что она используется в качестве основной для проверки в известном фреймворке NestJS. Не углубляясь в подробности фреймворка (этому можно уделить отдельную статью), у вас из коробки есть возможность включить валидацию запросов на уровне всего API, либо на уровне конкретного контроллера или обрабортчика.


async function bootstrap() {
  const app = await NestFactory.create(ApplicationModule);
  app.useGlobalPipes(new ValidationPipe()); // turn on all controllers validation
  await app.listen(3000);
}
bootstrap();

@UsePipes(new ValidationPipe()) // turn on this controller validation
@Controller()
export class AppController {
  @Get()
  healthCheck(): string {
    return 'up and running';
  }
}

Оба примера включают проверку запросов перед вызовом конкретного обработчика. Проверка осуществляется с помощью class-validator.


Заключение


Пожалуй, одно из основных качеств хорошего кода — это его предсказуемость. Когда программа работает так, как вы ожидаете в любой ситуации. В данной статье я постарался изложить некоторые мысли и подходы о том, как снизить количество нештатных ситуаций при работе с Typescript и тем самым увеличить предсказуемость программы. Лично для меня использование валидации в рантайме немного приближает JS / TS к привычным для меня языкам с более строгой типизацией, уменьшая тем самым потенциальное количество ошибок. Таким образом получается совместить положительные качества гибкого интерпретируемого Javascript и строгого языка типа C#. Тем самым инвестируя свое время в проектирование и написание кода, а не в бесконечное отлавливание ошибок.


Пишите качественный предсказуемый код с удовольствием!

Tags:
Hubs:
+13
Comments 11
Comments Comments 11

Articles