Строгий JS
Тёмин
бложег

Строгий JS

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

Умные ребята придумали специальные выручалочки. Майкрософт разработали TypeScript — целый новый статически типизированный язык программирования, который компилируется в JS. А в Фейсбуке придумали Flow — надстройку над Джаваскрпитом, которая добавляет аннотации типов данных.

TypeScript и Flow хороши, но требуют специальных систем сборки и защищают от ошибок только на этапе компиляции. Тем не менее добавить предсказуемости в программу на JS можно встроенными средствами языка.

Дисклеймер

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

Для начала поговорим о некоторых нововведениях Джаваскрипта.

Замороженные и запечатанные объекты

По умолчанию вы можете добавлять или изменять любые поля любых объектов. На самом деле, это не совсем так, но для простоты мы это опустим. Чтобы изменить умолчательное поведение, воспользуемся двумя интересными методами глобального объекта Object (это тавтология? :-): Object.freeze и Object.seal.

Что они делают? Object.freeze запрещает любые изменения свойств переданного объекта:

const obj = {name: 'Vasya', age: 28};
const frozen = Object.freeze(obj);
frozen.age = 33; // TypeError
frozen.lastName = 'Pupkin'; // TypeError

Как мы видим, попытка изменения существующего или добавление нового свойства вызывает ошибку TypeError. Неплохо, но для нас это перебор. Лучше воспользуемся методом Object.seal, он похож на предыдущий, но не запрещает менять существующие свойства:

const obj = {name: 'Vasya', age: 28};
const frozen = Object.freeze(obj);
frozen.age = 33; // OK!
frozen.lastName = 'Pupkin'; // TypeError

С этими знаниями можно описать класс с защитой от добавления левых свойств:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    Object.seal(this);
  }
}

Надо учитывать один момент — по стандарту родительский конструктор можно вызвать только в самом верху дочернего, а такой код вызовет ошибку

class BaseObject {
  constructor() {
    Object.seal(this)
  }
}

class Person extends BaseObject {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
    super(); // Так сделать не получится :-(
  }
}

Из-за этой особенности вызов Object.seal придётся дублировать в каждом классе, ну или пользоваться каким-нибудь фабричным методом:

function instanciate(constr, ...args) {
  return Object.seal(new constr(...args));
}

Более подробно про заморозку объектов при инстанцировании у Акселя.

Понятные названия типов

У всех объектов в Джаваскритпе есть метод toString, возвращающий их строковое представление. В ваших собственных классах его можно переопределить:

class Person {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  toString() {
    return this.firstName + ' ' + this.lastName;
  }
}

const obj = new Prson('Vasya', 'Pupkin');
console.log(obj.toString());

Один из типичных способов использования этого метода — определение типа объекта:

const arr = [1, 2, 3];
console.log(Object.prototype.toString.call(arr)) // [object Array]

Но что будет, если мы провернем то же самое для нашего класса Person?

const obj = new Person('Vasya', 'Pupkin');
console.log(Object.prototype.toString.call(obj)); // [object Object]

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

Object.prototype.toString = function () {
  return '[object ' + this[Symbol.toStringTag] + ']';
}

Внутри себя этот метод использует специальное свойство [Symbol.toStringTag] — так называемый широко известный (well-known) символ. Подробнее про них можно прочитать здесь. Строковый тег можно определить свой:

class Person {
// ...

  get [Symbol.toStringTag]() {
    return 'Person';
  }
}

const obj = new Prson('Vasya', 'Pupkin');
console.log(Object.prototype.toString.call(obj)); // [object Person]

Теперь всегда можно понять, с каким объектом мы имеем дело.

Приведение к примитивному типу

Автоматическое приведение типов в Джаваскрипте работает в выражениях, например математических:

console.log(2 + '2') // 22
console.log(2 - '2') // 0
console.log(2 * '2') // 4

Эффект приведения зависит от типов операндов и выбранного действия. Любая операция в JS что-нибудь да вернёт. Такой подход избавляет от ошибок в рантайме, но приводит к неожиданным последствиям: результатом сложения может оказаться строка или вообще какой-нибудь not-a-number.

Добавим контроля с помощью широко известного символа Symbol.toPrimitive:

class Person {
// ...

  get [Symbol.toStringTag]() {
    return 'CommonObject';
  }

  [Symbol.toPrimitive](hint) {
    const type = Object.prototype.toString.call(this);
    throw new TypeError(`${type} cannot be converted to
    ${hint === 'default' ? 'primitive value' : hint}`);
  }
}

const obj = new Person('Vasya', 'Pupkin')

obj + 2 // TypeError

Этот метод будет автоматически вызван движком при выполнении приведения типов. Он принимает параметр hint — желаемый тип, но мы всегда кидаем исключение. Если уж так хочется конвертировать человека в строку или число, всегда можно вызвать соответствующий метод явным образом!

Подробнее про Symbol.toPrimitive можно узнать здесь.

Итог

Вот что мы получили в конце:

class CommonObject {
  static instantiate(constr, ...args) {
    return Object.seal(new constr(...args));
  }

  get [Symbol.toStringTag]() {
    return 'CommonObject';
  }

  [Symbol.toPrimitive](hint) {
    const type = Object.prototype.toString.call(this);
    throw new TypeError(`${type} cannot be converted to
    ${hint === 'default' ? 'primitive value' : hint}`);
  }
}

class Person extends CommonObject {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }

  toString() {
    return this.firstName + ' ' + this.lastName;
  }
}

// Создаем новые экземпляры так ¯\_(ツ)_/¯:
const obj = CommonObject.instantiate(Person, 'Vasya', 'Pupkin');
obj.firstName = 'Petya'; // OK!
obj.age = 33; // TypeError
obj + 2; // TypeError
'hello ' + obj; // TypeError
'hello ' + obj.toString() // hello Petya Pupkin

Вот таким нехитрым способом можно упорядочить хаос чересчур динамического языка.