Приветствую! Меня зовут Андрей Степанов, я CTO во fuse8. Мне интересно знакомиться с опытом коллег по цеху и делиться своим. В сфере я уже больше 20 лет. В этой статье приведены примеры ситуаций, с которыми вы можете столкнуться, если работаете с TypeScript, но не знакомы с некоторыми тонкостями о них и поговорим. Огромная благодарность за составление и помощь в подготовке материала разработчикам Дмитрию Бердникову и Александру Инкееву!

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

Получение any вместо unknown

Если используем тип any, то теряем типизацию — мы можем обратиться к любому методу или свойству такого объекта, и компилятор не предупредит нас о возможных ошибках. Если же мы используем unknown, то компилятор подскажет о них. 

Некоторые функции и операции возвращают any по умолчанию - это не совсем очевидно, вот несколько примеров:

// JSON.parse
const a = JSON.parse('{ a: 1 }'); // any


// Array.isArray
function parse(a: unknown) {
  if (Array.isArray(a)) {
    console.log(a); // a[any]
  }
}


// fetch
fetch("/")
  .then((res) => res.json())
  .then((json) => {
    console.log(json); // any
  });


// localStorage, sessionStorage
const b = localStorage.a; // any
const c = sessionStorage.b // any

Эту проблему может решить ts-reset

ts-reset – это библиотека, которая помогает решить некоторые неочевидные моменты, когда хотелось бы, чтобы TypeScript по умолчанию работал иначе.

Методы массивов слишком строгие для конструкции as const

Также это встречается в методах has у Set и Map.

Пример: создаем массив пользователей, присваиваем конструкцию as const, затем вызываем метод includes и получаем ошибку, потому что аргумент 4 не существует в типе userIds.

const userIds = [1, 2, 3] as const;

userIds.includes(4);

Избавиться от ошибки также поможет использование ts-reset.

Отфильтровать массив от undefined

Предположим, у нас есть какой-то числовой массив, в котором может быть undefined. Чтобы избавиться от этих undefined, отфильтруем массив. Но массив newArr всё равно будет содержать тип массива number или undefined.

const arr = [1, 2, undefined];
const newArr = arr.filter((item) => item !== undefined);

Решить проблему можно следующим образом, тогда newArr2 будет иметь тип number:

const newArr2 = arr.filter((item): item is number => item !== undefined);

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

Сужение типа с помощью скобочной нотации

Создаем объект с типом ключ строка, значение строка или массив строк. 

Затем обращаемся к свойству объекта, используя скобочную нотацию и проверяем, что тип возвращаемого значения объекта является строкой. В typescript версии ниже 4.7 тип queryCountry будет строкой или массивом строк, т.е. автоматическое сужение типов не работает, хотя мы уже проверили условием. 

Если же использовать typescript версии 4.7 и выше, сужение типа будет работать так, как мы этого ожидаем.

const query: Record<string, string | string[]> = {};

const COUNTRY_KEY = 'country';

if (typeof query[COUNTRY_KEY] === 'string') {
    const queryCountry: string = query[COUNTRY_KEY];
}

Ссылка на документацию

Проблемы enum

Создаем enum и явно не указываем значения, тогда у каждого ключа по порядку будут числовые значения от 0 и далее.

С помощью этого enum затипизируем первый аргумент функции showMessage, в ожидании, что мы сможем передать только те коды, которые описаны в enum:

enum LogLevel {
    Debug, // 0
    Log, // 1
    Warning, // 2
    Error // 3
}

const showMessage = (logLevel: LogLevel, message: string) => {
    // code...
}

showMessage(0, 'debug message');
showMessage(2, 'warning message');

Если же передать не содержащееся в enum значение в качестве аргумента, мы должны увидеть ошибку "Argument of type '-100' is not assignable to parameter of type 'LogLevel'".

Но в typescript ниже версии 5.0 такой ошибки нет, хотя по логике она должна быть:

showMessage(-100, 'any message')

Также мы можем создать enum и явно указать числовые значения. Константе a указываем тип enum и присваиваем любое несуществующее число, которого нет в enum, например, 1. 

При использовании TS ниже 5 версии ошибки не будет.

enum SomeEvenDigit {
    Zero = 0,
    Two = 2,
    Four = 4
}

const a: SomeEvenDigit = 1;

И еще момент: при использовании TypeScript ниже 5 версии, вычисляемые значения не могут быть использованы в enum.

enum User {
  name = 'name',
    userName = `user${User.name}`
}

Ссылка на документацию.

Функции, у которых явно указан возвращаемый тип undefined, должны иметь явный возврат

В версиях TypeScript ниже 5.1 будет появляться ошибка в случаях, когда у функции явно указан тип undefined, но нет return.

function f4(): undefined {}

Ошибки не будет в следующих случаях:

function f1() {}

function f2(): void {}

function f3(): any {}

Закрепим. Если явным образом присвоить функции тип void или any, ошибки не будет. Она появится, если присвоить функции тип undefined, и только при использовании TypeScript версии ниже 5.1.

Ссылка на документацию.

Поведение enum’ов соответствует номинативной типизации, а не структурной

И это несмотря на то, что у TypeScript типизация, наоборот, структурная.

Создадим enum и функцию, аргумент которой типизируем этим enum. Попробуем вызвать функцию, передав в качестве значения этого аргумента строку, которая идентична одному из значений enum. Получаем ошибку в showMessage тип аргумента 'Debug' не может быть присвоен, так как ожидается тип enum 'LogLevel'.

enum LogLevel {
    Debug = 'Debug',
    Error = 'Error'
}

const showMessage = (logLevel: LogLevel, message: string) => {
    // code...
}

showMessage('Debug', 'some text')

Даже если мы создадим новый enum с такими же значениями, это не сработает.

enum LogLevel2 {
    Debug = 'Debug',
    Error = 'Error'
}
showMessage(LogLevel2.Debug, 'some text')

Решение – использовать объекты со значением as const.

const LOG_LEVEL = {
    DEBUG: 'debug',
    ERROR: 'error'
} as const

type ObjectValues<T> = T[keyof T]

type LogLevel = ObjectValues<typeof LOG_LEVEL>;

const logMessage = (logLevel: LogLevel, message: string) => {
    // code...
}

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

logMessage('debug', 'some text')
logMessage(LOG_LEVEL.DEBUG, 'some text')

Возможность возврата неправильного типа данных в функции с перегрузкой

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

function add(x: string, y: string): string
function add(x: number, y: number): number
function add(x: unknown, y: unknown): unknown {

    if (typeof x === 'string' && typeof y === 'string') {
                return 100;
    }

    if (typeof x === 'number' && typeof y === 'number') {
        return x + y
    }

    throw new Error('invalid arguments passed');
}

Далее ожидаем, что const будет содержать тип string, но получаем число.

const str = add("Hello", "World!");
const num = add(10, 20);

Передача объекта как аргумент функции с лишним свойством

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

Однако в typescript возможно нарушить это правило:

type Func = () => {
  id: string;
};

const func: Func = () => {
  return {
    id: "123",
    name: "Hello!",
  };
};

Для большей наглядности, создадим объект с настройками formatAmountParams, который передадим в функцию formatAmount. Как можно увидеть, объект с настройками может содержать лишние свойства и ошибки никакой не будет.

type FormatAmount = {
  currencySymbol?: string,
  value: number
}

const formatAmount = ({ currencySymbol = '$', value }: FormatAmount) => {
  return `${currencySymbol} ${value}`;
}

const formatAmountParams = {
  currencySymbol: 'USD',
  value: 10,
  anotherValue: 20
}

Нет ошибки, если передаем объект, который содержит лишние свойства:

formatAmount(formatAmountParams);

Получим ошибку, если создадим объект как аргумент функции и передадим с лишним свойством.

formatAmount({ currencySymbol: '', value: 10, anotherValue: 12 });

Можем столкнуться с неочевидным поведением, если захотим переименовать currencySymbol на currencySign.

Сначала изменим в типе, затем typescript подскажет, что надо изменить ключ в объекте с  currencySymbol на currencySign.

type FormatAmount = {
  currencySign?: string,
  value: number
}

const formatAmount = ({ currencySign = '$', value }: FormatAmount) => {
  return `${currencySign} ${value}`;
}

const formatAmountParams = {
  currencySymbol: 'USD',
  value: 10
}

formatAmount(formatAmountParams);

Ошибок нет – можно подумать, что рефакторинг прошел без проблем. Но в formatAmountParams осталось старое название currencySymbol и вместо ожидаемого результата 'USD 10' мы получим $10'.

Потеря типизации при использовании Object.keys

Создадим объект obj. С помощью Object.keys создадим массив с ключами объекта и проитерируемся по этому массиву. Если в цикле обратимся к объекту по ключу, typescript скажет, что не можем этого сделать, так как общий тип 'string' не может быть использован в качестве ключа для объекта obj.

Возможное решение – скастовать тип с помощью конструкции as. Но это может быть небезопасно, потому что мы вручную устанавливаем, какой тип там будет находиться. Нужно привести к тому, чтобы [key] был не просто строкой, а ключом, и явно это указать.

const obj = {a: 1, b: 2}

Object.keys(obj).forEach((key) => {
  console.log(obj[key])
  console.log(key as keyof typeof obj)
});

TypeScript может не распознать изменение типа данных

Создадим тип UserMetadata, как Map ключ-значение. На основе этого типа создаём cache и пытаемся получить значение по ключу 'foo' с помощью метода get. Всё работает как ожидается.

Затем создадим объект cacheCopy на основе cache. И также вызываем метод get. Typescript не подскажет, что что-то не так, но будет ошибка, так как у объекта нет метода get.

type Metadata = {};

type UserMetadata = Map<string, Metadata>;

const cache: UserMetadata = new Map();

console.log(cache.get('foo'));

const cacheCopy: UserMetadata = { ...cache };

console.log(cacheCopy.get('foo'));

Мерж интерфейсов

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

interface User {
    id: number;
}

interface User {
    name: string;
}

// Error: Property 'id' is missing in type '{ name: string; }' but required in type 'User', because User interfaces merged
const user: User = {
    name: 'bar',
}

Более того, если у нас есть глобальные интерфейсы, например, предопределенные в самом typescript, они также смержатся. Например, если создадим интерфейс с именем comment, получим мерж интерфейсов, потому что comment уже существует в lib.dom.d.ts.

interface Comment {
  id: number;
  text: string;
}

// Error: Type '{ id: number; text: string; }' is missing the following properties from type 'Comment': data, length, ownerDocument, appendData, and 59 more.
const comment: Comment = {
  id: 5,
  text: "good video!",
};

Ссылка на документацию.

Еще полезное

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

Комментарии (8)


  1. ruslan_astratov
    22.11.2023 09:50
    +1

    Спасибо. Очень полезная статья


  1. berdnikovdim
    22.11.2023 09:50
    +1

    Интересно было бы сравнить как это работает в других языках, например, будет ли отличаться поведение в Rescript


  1. descrybe
    22.11.2023 09:50
    +1

    Спасибо за статью!


  1. Sklott
    22.11.2023 09:50
    +1

    Передача объекта как аргумент функции с лишним свойством

    Помоему это совсем не проблема, и оно так работает во всех ООП языках. Всегда можно использовать объект с типом потомка там, где используется тип базового класса.

    То что в TS "наследование" может быть неявным - это уже отдельный разговор...

    А то, что возникает ошибка когда вы передаете литеральный объект - ну там просто не происходит "наследования" и лишние проперти уже лишние.


    1. Alexandroppolus
      22.11.2023 09:50

      А то, что возникает ошибка когда вы передаете литеральный объект - ну там просто не происходит "наследования" и лишние проперти уже лишние.

      Скорее всего, это частный случай ради удобства. С точки зрения TS, лишние поля литерала всё равно недоступны "законными" способами (без приведения типа) и потому бесполезны, и лучше нарисовать подсказку, что их нет в типе.


    1. berdnikovdim
      22.11.2023 09:50
      +1

      Спасибо за дополнение, в данном случае возникает ошибка в литерале не потому что не происходит "наследования", а как написал@meonsou,это можно рассмотреть как правило линтера https://www.typescriptlang.org/tsconfig#suppressExcessPropertyErrors, которое можно отключить

      Само поведение получается неочевидным, что с помощью объекта нет ошибки, с помощью литерала есть


  1. meonsou
    22.11.2023 09:50
    +1

    Немного дополню.

    Если же передать не содержащееся в enum значение в качестве аргумента, мы должны увидеть ошибку "Argument of type '-100' is not assignable to parameter of type 'LogLevel'". Но в typescript ниже версии 5.0 такой ошибки нет, хотя по логике она должна быть.

    В 5.0 починили только самые очевидные случаи, когда литерал явно не подходит, но например такое всё ещё работает:

    let level = -100
    showMessage(level, 'debug message')

    Не смотря на то, что number не совместим с LogLevel.

    Передача объекта как аргумент функции с лишним свойством

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

    Что касается этих ошибок, это сделано больше как правило линтера. Когда напрямую пытаешься запихнуть объектный литерал с лишними свойствами ТС считает что это скорее всего баг и предупреждает пользователя. Это кстати можно отключить настройкой suppressExcessPropertyErrors в тсконфиге.

    Потеря типизации при использовании Object.keys

    Не указано почему так происходит - дело в том что здесь очень высока вероятность несовпадения типов с рантаймом. Например:

    const c1: { a: number, b: number } = { a: 1, b: 2 }
    
    const c2: { a: number } = c1
    
    const c3: { a: number, c?: number } = c2

    Получаем ключи ["a", "c"] в типах и ["a", "b"] в рантайме.


    1. andrey_stepanov1 Автор
      22.11.2023 09:50

      Спасибо большое за уточнения, очень ценно!