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

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

Относительная простота и доступность 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 прекрасный довольно удобный язык, порог входа низкий, а развивается он здорово в последние годы. С каждым нововведением писать становится все удобнее и удобнее. Но это интерпретируемый язык без нормальной типизации. Подробнее о видах типизации хорошо написано тут

image

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

Typescript мне помог в этом. Будучи чем-то среднем между JS и тем же C# (конечно, их же  придумал один и тот же чел - Андерс Хейлсберг). Интересно еще то, что он же придумал 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 - гораздо лучше:

Все продолжит работать, ведь наша функция ожидает 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 и типы данных в рантайме

Все бы хорошо, и мы уже почти подошли к сути, вот только после компилирования получается что? Чистейший 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 не знает что делать, а результатом должно быть число. Об этом JS догадается по оператору умножения. Если же имеем дело с различного рода объектами, и функция получит объект, который не ожидался, все равно будет попытка работы с полями объекта. Соответственно мы рано или поздно получим плохо отлавливаемый undefined.

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

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

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

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

Истина в простоте и ожидаемом результате

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

Во-первых, я пришел из 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% случаев

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

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

  • 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 будет по сути функцией, поэтому чтобы узнать сам ожидаемый тип, эту функцию надо вызвать, и только после этого использовать typeof, тогда мы получим желаемый тип аргумента, как в тайпскрипте.

typeof param()

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

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

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

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

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

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

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

Я подготовил небольшой npm пакет, который содержит первую версию декоратора @Typed , его можно скачать и попробовать в деле.

ts-stronger-types
Runtime checking of types and integrity for Typescript projects

Я буду признателен, за реальную обратную связь в виде 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#. Тем самым инвестируя свое время в проектирование и написание кода, а не в бесконечное отлавливание ошибок.

Пишите код с удовольствием!