Выживание в экосистеме TypeScript - Часть 2: строгие флаги компилятора

Источник на Github: Демонстрация TypeScript

Небольшое примечание, прежде чем мы перейдем к этому: все примеры в этом посте используют TypeScript v2.9.1. Если вы видите другое поведение, проверьте свою версию. Время от времени я буду стараться обновлять примеры с помощью обновлений TypeScript.

Вступление

Одна из самых запутанных вещей в TypeScript - это то, как он может вести себя по-разному в зависимости от того, как он настроен. Конфигурация может полностью изменить то, как работает базовая проверка типов. По умолчанию я считаю, что TypeScript вызывает больше проблем, чем исправляет. Обычно это проявляется в замешательстве разработчиков. Там, где я работал с командами, у нас есть файлы команды / компании tsconfig.json, которые уже настроены для включения строгих флагов компилятора.

Что такое строгие флаги компилятора?

Это в основном вопрос, на который ответит этот пост. Мы рассмотрим несколько строгих параметров компилятора и посмотрим, что это значит, когда они выключены или включены. В TypeScript (начиная с серии 2.x) все они по умолчанию отключены. Это означает, что когда вы загружаете TypeScript и просто начинаете компилировать код в первый раз, вы не получите ничего близкого к правильной проверке типов.

Мы рассмотрим четыре конкретных флага:

  1. Неявное приведение к любому
  2. Неявное приведение this к любому
  3. Строгие типы функций
  4. Строгие нулевые проверки

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

Подготовка к настройке

Если вы хотите продолжить, создайте проект, как я делал во введении к этой серии:.

Неявное приведение к любому

$ git checkout implicit-any

Для начала давайте взглянем на кусок работающего кода TypeScript.

interface IPerson {
    name: {
        first: string
        last?: string
    }
}
function getName(person: IPerson): string {
    return person.name.first
}

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

const person: IPerson = {
    name: {
        first: 'John',
        last: 'Doe',
    }
}
console.log(`First Name: ${getName(person)}`)

Здесь все в порядке. Мы можем скомпилировать и запустить это и получить ожидаемый результат.

$ npm run build
$ node build/index.js
First Name: John

Однако проблемы возникают, если мы вызываем функцию getName в другом контексте. Давайте заменим console.log (…) в нашем предыдущем примере чем-то явно неправильным.

function sayHello(obj) {
    console.log(`Hello, ${getName(obj)}`)
}
sayHello('Mary')

Теперь давайте попробуем построить и запустить это.

$ npm run build
$ node build/index.js
TypeError: Cannot read property 'first' of undefined

Хорошо, все компилируется без проблем, но когда мы запускаем его, возникает ошибка времени выполнения JavaScript и трассировка стека. Почему я вообще использую TypeScript? Я сдаюсь.

Вероятно, это проблема номер один, которую я видел у людей с TypeScript. Они будут смотреть на функцию, которая выдает ошибки, в данном случае на нашу функцию getName, смотрят на нее и задаются вопросом, как эта ошибка возможна. В этой функции никогда не должно быть ничего, что не относится к типу IPerson, и любой объект, относящийся к этому интерфейсу, должен иметь свойство name.

Все эти мысли имеют смысл. Проблема возникает в том, как TypeScript ведет себя по умолчанию. Если он не может определить тип значения, он неявно приведет это значение как «любой» тип. В состоянии по умолчанию я почти нахожу TypeScript скорее проблемой, чем решением. Если я не могу постоянно полагаться на типы, возможно, лучше даже не притворяться, а просто предполагать, что дела идут плохо. Я помог многим людям освоить TypeScript, и большинство из них в какой-то момент столкнулось с этой путаницей. Настоящая проблема заключается не в простой (и явно неправильной) ситуации, как в нашем примере, а в том, что если значение неявно приводится к «любым» пяти функциям вверх по стеку вызовов или десяти функциям вверх - до двадцати. Может быть трудно точно отследить, где наши типы облажались. Все в модуле, над которым мы работаем, выглядит логично и правильно.

Это подводит нас к нашему первому флагу компилятора (- noImplicitAny). Я считаю, что вам даже не следует пытаться использовать TypeScript без установки этого флага, просто используйте JavaScript и черт побери. Этот флаг предотвращает неявное приведение к любому типу. Используя это, если TypeScript не может определить тип, вы получите ошибку времени компиляции. Давайте перекомпилируем наш предыдущий пример, используя этот флаг.

$ npm run build -- --noImplicitAny true
Parameter 'obj' implicitly has an 'any' type.

Да, это то, что мы хотим. Мы написали заведомо неверный код. Об этом должен сообщить компилятор. Он должен сообщить нам, где именно мы ошиблись (номер строки и столбца), без поиска ошибок времени выполнения в трассировке стека.

Пришел этот флаг, и это хорошо. Давайте добавим его в наш tsconfig.

{
    "compilerOptions": {
        "target": "es2015",
        "module": "commonjs",
        "moduleResolution": "node",
        "pretty": true,
        "removeComments": true,
        "rootDir": "./src",
        "outDir": "./build",
        "noImplicitAny": true
    },
    "exclude": [ "node_modules" ]
}

Неявное приведение этого к любому

$ git checkout implicit-this

JavaScript допускает много странных вещей. Мы собираемся взглянуть на одну из этих странных вещей. Возникает вопрос, что такое «это» в JavaScript. Я удаляю все в index.ts и начинаю с этого:

function getObjectName(): string {
    return this.name
}

Это приводит к очень похожей проблеме с неявным преобразованием переменных / параметров в «любые». Здесь мы имеем ситуацию, когда «this» можно неявно привести к «любому».

const person = {
    name: 'Louis',
    getName: getObjectName,
}
console.log(`Name: ${person.getName()}`)

Надеюсь, ты не делаешь таких вещей. То, что JavaScript позволяет что-то, не означает, что человек должен это делать. Однако этот код работает.

$ npm run build
$ node build/index.js
Name: Louis

Хорошо, но нетрудно придумать способ сломать это.

const thing = {
    getName: getObjectname,
}
console.log(`Name: ${thing.getName()}`)

И для полноты картины запустим это.

$ npm run build
$ node build/index.js
Name: undefined

Компилятор не должен позволять нам этого делать. Во время компиляции очевидно, что объект «вещь» не имеет свойства «имя», которое мы могли бы прочитать. Наш последний флаг компилятора был «- noImplicitAny». Наш следующий флаг компилятора - «- noImplicitThis».

$ npm run build -- --noImplicitThis true
'this' implicitly has type 'any' because it does not have a type annotation.

Как это исправить? Как мы предоставляем аннотацию типа для «этого».

function getObjectName(this: { name: string }): string {
    return this.name
}

TypeScript рассматривает this как неявный аргумент функции. Затем вы можете предоставить аннотацию типа в сигнатуре функции. С помощью этого исправления компилятор поймет, что объекту «вещь» нельзя разрешать вызывать эту функцию.

$ npm run build -- --noImplicitThis true
Property 'name' is missing in type '{ getName: (this: { name: string; }) => string; }'

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

{
    "compilerOptions": {
        "target": "es2015",
        "module": "commonjs",
        "moduleResolution": "node",
        "pretty": true,
        "removeComments": true,
        "rootDir": "./src",
        "outDir": "./build",
        "noImplicitAny": true,
        "noImplicitThis": true
    },
    "exclude": [ "node_modules" ]
}

Строгие типы функций

$ git checkout strict-functions

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

class Animal {
    public readonly species: string
    constructor(species: string) {
        this.species = species
    }
}
class Dog extends Animal {
    constructor() {
        super('Dog')
    }
    public bark(): void {
        console.log('Bark! Bark!')
    }
}
class Cat extends Animal {
    constructor() {
        super('Cat')
    }
    public meow(): void {
        console.log('Meeeeoooow')
    }
}

Глядя на эти три класса, давайте сделаем небольшое умственное упражнение. Скажем, мы должны были создать функции для работы с этими классами. Если я напишу функцию, которая принимает Animal, она должна принять любой из своих подклассов, потому что все подклассы будут иметь свойства и методы Animal.

function getSpecies(animal: Animal): string {
    return animal.species
}
console.log(getSpecies(new Dog()))

Однако, если я напишу функцию, которая принимает Dog, она должна иметь возможность принимать только Dog или подкласс Dog.

function makeBark(dog: Dog): void {
    dog.bark()
}
makeBark(new Dog())

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

type AnimalAction = (animal: Animal) => void

Что мы можем присвоить переменной этого типа?

const action: AnimalAction = makeBark

Должны ли мы это допустить? Нет, потому что любая функция, которая принимает Animal, как AnimalAction, может принимать любой подтип Animal. Разрешение этого позволяет нам делать что-то вроде этого:

action(new Cat())

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

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

$ npm run build
$ node build/index.js
TypeError: dog.bark is not a function

Да, опять же, он компилируется безопасно, но выдает исключение во время выполнения. Как вы, наверное, догадались, мы можем исправить это с помощью флага компилятора. На этот раз мы собираемся использовать «- strictFunctionTypes».

$ npm run build -- --strictFunctionTypes true
Type '(dog: Dog) => void' is not assignable to type 'AnimalAction'.

Да именно то, что мы хотим. Теперь он правильно проверяет типы функций. Мы добавим это в config и в наш постоянно растущий список флагов компилятора.

{
    "compilerOptions": {
        "target": "es2015",
        "module": "commonjs",
        "moduleResolution": "node",
        "pretty": true,
        "removeComments": true,
        "rootDir": "./src",
        "outDir": "./build",
        "noImplicitAny": true,
        "noImplicitThis": true,
        "strictFunctionTypes": true
    },
    "exclude": [ "node_modules" ]
}

Примечание. Дополнительные сведения об этой проверке можно найти в статье Что такое ковариация и контравариантность?

Строгие проверки на нуль

$ git checkout strict-null

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

Чисто говоря, когда вы говорите, что что-то является типом, вы объявляете правила, которые определяют набор значений, которые разрешено иметь типу. Большинство языков, в которых используется концепция «нулевого» или «нулевого» значения, позволяют этому значению быть в наборе почти любого типа объекта.

interface IUser {
    name: string
}
const user: IUser = null

Это хорошо. Переменная user - это IUser, она просто отсутствует. Чего ждать?

interface IUser {
    name: string
}
function getName(obj: IUser): string {
    return obj.name
}
const user: IUser = null
console.log(`Name: ${getName(user)}`)

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

$ npm run build
$ node build/index.js
TypeError: Cannot read property 'name' of null

Ура, ошибка времени выполнения и трассировка стека. Самое любимое в жизни любого программиста. TypeScript позволяет нам избежать этого, давая нам возможность удалить null (и undefined) из набора значений, которые могут быть присвоены переменной данного типа.

Мы делаем это с помощью. " - strictNullChecks ».

$ npm run build -- --strictNullChecks true
Type 'null' is not assignable to type 'IUser'.

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

Этот флаг заставляет нас писать более правильный код. Если нам нужна возможность установить для данного объекта значение null, мы должны явно добавить null к набору значений, которые он принимает.

const user: IUser | null = null

Здесь говорится, что «пользователь» может быть IUser или null. Это другой тип, чем просто IUser. Теперь компилятор заставит нас выполнить нулевую проверку перед вызовом «getUser».

$ npm run build -- --strictNullChecks
Argument of type 'null' is not assignable to parameter of type 'IUser'.

В нашем коде мы можем заключить вызов getUser в нулевую проверку. Внутри блока if TypeScript достаточно умен, чтобы знать, что наша проверка null удалила null как возможность из набора значений, которые пользователь мог быть в этой точке.

if (user !== null) {
    console.log(`Name: ${getName(user)}`)
} else {
    console.log('User is missing')
}

Теперь все скомпилируется и запустится без проблем.

$ npm run build -- --strictNullChecks true
$ node build/index.js
User is missing

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

{
    "compilerOptions": {
        "target": "es2015",
        "module": "commonjs",
        "moduleResolution": "node",
        "pretty": true,
        "removeComments": true,
        "rootDir": "./src",
        "outDir": "./build",
        "noImplicitAny": true,
        "noImplicitThis": true,
        "strictFunctionTypes": true,
        "strictNullChecks": true,
    },
    "exclude": [ "node_modules" ]
}

Заключение

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

{
    "compilerOptions": {
        "target": "es2015",
        "module": "commonjs",
        "moduleResolution": "node",
        "pretty": true,
        "removeComments": true,
        "rootDir": "./src",
        "outDir": "./build",
        "strict": true
    },
    "exclude": [
        "node_modules"
    ]
}

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

Использование флага компилятора strict включает следующие параметры:

  • noImplicitAny
  • noImplicitThis
  • alwaysStrict
  • strictNullChecks
  • strictFunctionTypes
  • strictPropertyInitialization

Мы не рассматривали «alwaysStrict» или «strictPropertyInitialization». Итак, «alwaysStrict» - это флаг, позволяющий всегда использовать строгий режим JavaScript. Параметр «strictPropertyInitialization» аналогичен «strictNullChecks», он обеспечивает инициализацию свойств класса до того, как конструктор завершит выполнение.

Еще статьи из этой серии

Дальнейшее чтение