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

  1. Тип ошибки TypeScript
  2. Область видимости переменных
  3. Вложение

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

Проблема 1: тип ошибки TypeScript

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

try {
  throw new Error('oh no!')
} catch (error) {
  console.dir(error)
}

В конечном итоге вы увидите объект, который выглядит следующим образом:

{
  message: 'oh no!'
  stack: 'Error: oh no!\n at <anonymous>:2:8'
}

Это кажется простым, а как насчет TypeScript? Одна из первых вещей, которые я заметил, это то, что когда вы используете блок try/catch и проверяете тип error, вы обнаруживаете, что его тип — unknown.

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

try {
  throw new Error('oh no!')
} catch (error) {
  console.log((error as Error).message)
}ttt

Этот подход, вероятно, работает для 99,9% обнаруженных ошибок. Но почему TypeScript делает ввод ошибок громоздким? Причина в том, что на самом деле невозможно определить тип «ошибки», поскольку блок try/catch не перехватывает только ошибки; он ловит все, что бросают. В JavaScript (и TypeScript) вы можете использовать практически все, что показано ниже:

try {
  throw undefined
} catch (error) {
  console.log((error as Error).message)
}

Выполнение этого кода приведет к появлению новой ошибки в блоке «catch», что в первую очередь сводит на нет цель использования try/catch:

Uncaught TypeError: Cannot read properties of undefined (reading 'message') at <anonymous>:4:20

Проблема возникает из-за того, что свойство message не существует в undefined, что приводит к TypeError в блоке catch. В JavaScript эту проблему могут вызвать только два значения: undefined и null.

В этом конкретном сценарии даже использование finally не поможет, кроме выполнения последнего действия перед TypeError:

try {
  throw undefined
} catch (error) {
  console.log((error as Error).message)
} finally {
  console.log('this will log');
}
console.log('code here is unreachable because "catch" threw a TypeError')

Теперь можно задаться вопросом о вероятности того, что кто-то выкинет undefined или null. Хотя это, вероятно, редко, но если это все же произойдет, это может привести к неожиданному поведению вашего кода. Более того, учитывая множество сторонних пакетов, обычно используемых в проекте TypeScript, неудивительно, если один из них случайно выдаст неверное значение.

Является ли это единственной причиной, по которой TypeScript устанавливает тип throws на unknown? На первый взгляд это может показаться редким крайним случаем, и приведение типов может показаться разумным решением. Однако это еще не все. Хотя undefined и null являются наиболее разрушительными случаями, поскольку они могут привести к сбою вашего приложения, могут быть выброшены и другие значения. Например:

try {
  throw false
} catch (error) {
  console.log((error as Error).message)
}

Основное отличие здесь в том, что вместо выдачи TypeError он просто вернет undefined. Хотя это менее разрушительно, поскольку не приведет к сбою вашего приложения напрямую, это может вызвать другие проблемы, такие как отображение undefined в ваших журналах. Более того, в зависимости от того, как вы используете значение undefined, это может косвенно привести к сбою приложения. Рассмотрим следующий пример:

try {
  throw false
} catch (error) {
  console.log((error as Error).message.trim())
}

Здесь вызов .trim() на undefined вызовет TypeError, что может привести к сбою вашего приложения.

По сути, TypeScript стремится защитить нас, обозначая тип перехватываемых объектов как unknown. Такой подход возлагает на разработчиков ответственность за определение правильного типа выдаваемого значения, помогая предотвратить проблемы во время выполнения.

Вы всегда можете защитить свой код, используя дополнительные операторы цепочки (?.), как показано ниже:

try {
  throw undefined
} catch (error) {
  console.log((error as Error)?.message?.trim?.())
}

Хотя этот подход может защитить ваш код, он использует две функции TypeScript, которые могут усложнить обслуживание кода:

  • Приведение типов подрывает гарантии TypeScript, гарантирующие соответствие переменных указанным типам.
  • Использование необязательных операторов цепочки для необязательного типа не вызовет никаких ошибок, если кто-то их пропустит из-за несоответствия типов.

Предпочтительным подходом было бы использование средств защиты типов TypeScript. Защитники типов — это, по сути, функции, которые обеспечивают соответствие определенного значения заданному типу, подтверждая, что его безопасно использовать по назначению. Вот пример защиты типа для проверки того, имеет ли перехваченная переменная тип Error:

/**
 * Type guard to check if an `unknown` value is an `Error` object.
 *
 * @param value - The value to check.
 *
 * @returns `true` if the value is an `Error` object, otherwise `false`.
 */
export const isError = (value: unknown): value is Error =>
  !!value &&
  typeof value === 'object' &&
  'message' in value &&
  typeof value.message === 'string' &&
  'stack' in value &&
  typeof value.stack === 'string'

Этот тип охранника прост. Сначала он гарантирует, что value не является ложным, что означает, что это не будет undefined или null. Затем он проверяет, является ли это объектом с ожидаемыми атрибутами.

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

const logError = (message: string, error: unknown): void => {
  if (isError(error)) {
    console.log(message, error.stack)
  } else {
    try {
      console.log(
        new Error(
          `Unexpected value thrown: ${
            typeof error === 'object' ? JSON.stringify(error) : String(error)
          }`
        ).stack
      )
    } catch {
      console.log(
        message,
        new Error(`Unexpected value thrown: non-stringifiable object`).stack
      )
    }
  }
}

try {
  const circularObject = { self: {} }
  circularObject.self = circularObject
  throw circularObject
} catch (error) {
  logError('Error while throwing a circular object:', error)
}

Создав функцию logError, которая использует защиту типа isError, мы можем безопасно регистрировать стандартные ошибки, а также любые другие выброшенные значения. Это может быть особенно полезно для устранения непредвиденных проблем. Однако нам нужно быть осторожными, поскольку JSON.stringify также может выдавать ошибки. Инкапсулируя его в собственный блок try/catch, мы стремимся предоставить более подробную информацию об объектах, а не просто регистрировать их строковое представление [object Object].

Более того, мы можем получить трассировку стека, ведущую к моменту создания нового объекта Error. Сюда будет включено место, где было создано исходное значение. Хотя этот метод не обеспечивает прямую трассировку стека из выброшенных значений, он предлагает трассировку из точки сразу после выдачи, что должно быть достаточным для отслеживания источника проблемы.

Задача 2. Область видимости переменных

Определение области действия, вероятно, является одной из наиболее распространенных проблем при обработке ошибок, применимых как к JavaScript, так и к TypeScript. Рассмотрим этот пример:

try {
  const fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
  console.error(`Unable to load file`)
  return
}

console.log(fileContent)

В этом случае, поскольку fileContent был определен внутри блока try, он недоступен за его пределами. Чтобы решить эту проблему, у вас может возникнуть соблазн определить переменную вне блока try:

let fileContent

try {
  fileContent = fs.readFileSync(filePath, 'utf8')
} catch {
  console.error(`Unable to load file`)
  return
}
console.log(fileContent)

Этот подход далеко не идеален. Используя let вместо const, вы делаете переменную изменяемой, что может привести к потенциальным ошибкам. Кроме того, это усложняет чтение кода.

Один из способов обойти эту проблему — обернуть блок try/catch в функцию:

const fileContent = (() => {
  try {
    return fs.readFileSync(filePath, 'utf8')
  } catch {
    console.error(`Unable to load file`)
    return
  }
})()

if (!fileContent) {
  return
}

console.log(fileContent)

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

Задача 3: Вложение

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

export const doStuff = async (): Promise<void> => {
  try {
    const fetchDataResponse = await fetch('https://api.example.com/fetchData')
    const fetchDataText = await fetchDataResponse.text()
    if (!fetchDataResponse.ok) {
      throw new Error(
        `Unexpected response while fetching data. Status: ${fetchDataResponse.status} | Status text: ${fetchDataResponse.statusText} | Body: ${fetchDataText}`
      )
    }
    let fetchData
    try {
      fetchData = JSON.parse(fetchDataText) as unknown
    } catch {
      throw new Error(`Failed to parse fetched data response as JSON: ${fetchDataText}`)
    }
    if (
      !fetchData ||
      typeof fetchData !== 'object' ||
      !('data' in fetchData) ||
      !fetchData.data
    ) {
      throw new Error(
        `Fetched data is not in the expected format. Body: ${fetchDataText}`
      )
    }
    const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    })
    const storeDataText = await storeDataResponse.text()
    if (!storeDataResponse.ok) {
      throw new Error(
        `Unexpected response while storing data. Status: ${storeDataResponse.status} | Status text: ${storeDataResponse.statusText} | Body: ${storeDataText}`
      )
    }
  } catch (error) {
    logError('An error occurred:', error)
  }
}

Вы можете заметить, что мы вызываем API .text() вместо .json(). Этот выбор обусловлен поведением fetch: вы можете вызвать только один из этих двух методов. Поскольку мы стремимся отобразить содержимое тела в случае сбоя преобразования JSON, мы сначала вызываем .text(), а затем вручную возвращаемся к JSON, гарантируя, что мы поймаем любые ошибки в процессе. Чтобы избежать загадочных ошибок, таких как:

Uncaught SyntaxError: Expected property name or '}' in JSON at position 42

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

export const doStuffV2 = async (): Promise<void> => {
  try {
    const fetchDataResponse = await fetch('https://api.example.com/fetchData')
    const fetchData = (await fetchDataResponse.json()) as unknown
    if (
      !fetchData ||
      typeof fetchData !== 'object' ||
      !('data' in fetchData) ||
      !fetchData.data
    ) {
      throw new Error('Fetched data is not in the expected format.')
    }
    const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    })
    if (!storeDataResponse.ok) {
      throw new Error(`Error storing data: ${storeDataResponse.statusText}`)
    }
  } catch (error) {
    logError('An error occurred:', error)
  }
}

Этот рефакторинг устранил проблему вложенности, но породил новую проблему: отсутствие детализации в отчетах об ошибках. Убрав проверки, мы становимся более зависимыми от самого сообщения об ошибке для понимания проблем. Как мы видели по некоторым ошибкам JSON.parse, это не всегда обеспечивает наилучшую ясность.

Учитывая все проблемы, которые мы обсудили, существует ли оптимальный подход для эффективной обработки ошибок?

Правильный путь

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

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

/** An `Error` object to safely handle `unknown` values being thrown. */
export class NormalizedError extends Error {
  /** The error's stack or a fallback to the `message` if the stack is unavailable. */
  stack: string = ''
  /** The original value that was thrown. */
  originalValue: unknown
  /**
   * Initializes a new instance of the `NormalizedError` class.
   *
   * @param error - An `Error` object.
   * @param originalValue - The original value that was thrown.
   */
  constructor(error: Error, originalValue?: unknown) {
    super(error.message)
    this.stack = error.stack ?? this.message
    this.originalValue = originalValue ?? error
    // Set the prototype explicitly, for `instanceof` to work correctly when transpiled to ES5.
    Object.setPrototypeOf(this, NormalizedError.prototype)
  }
}

Основное преимущество расширения объекта Error заключается в том, что оно ведет себя как стандартная ошибка. Создание собственного объекта ошибки с нуля может привести к осложнениям, особенно при использовании оператора instanceof для проверки его типа. Вот почему мы явно задаем прототип, гарантируя, что instanceof работает правильно, особенно когда код переносится в ES5.

Кроме того, все функции-прототипы из Error доступны для объектов NormalizedError. Конструкция конструктора также упрощает создание новых объектов NormalizedError, требуя, чтобы первый аргумент был фактическим Error. Вот преимущества NormalizedError:

  • Это всегда будет допустимая ошибка, поскольку конструктор требует Error в качестве первого аргумента.
  • Добавлено новое свойство originalValue. Это позволяет нам получить исходное значение, которое было выброшено, что может быть полезно для извлечения дополнительной информации из ошибки или во время отладки.
  • stack никогда не станет undefined. Во многих случаях выгоднее регистрировать свойство stack, поскольку оно содержит больше информации, чем свойство message. Однако TypeScript определяет его тип как string | undefined, в первую очередь из-за совместимости между средами (часто наблюдаемой в устаревших средах). Переопределяя тип и гарантируя, что он всегда будет строкой, его использование упрощается.

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

/**
 * Converts an `unknown` value that was thrown into a `NormalizedError` object.
 *
 * @param value - An `unknown` value.
 *
 * @returns A `NormalizedError` object.
 */
export const toNormalizedError = <E>(
  value: E extends NormalizedError ? never : E
): NormalizedError => {
  if (isError(value)) {
    return new NormalizedError(value)
  } else {
    try {
      return new NormalizedError(
        new Error(
          `Unexpected value thrown: ${
            typeof value === 'object' ? JSON.stringify(value) : String(value)
          }`
        ),
        value
      )
    } catch {
      return new NormalizedError(
        new Error(`Unexpected value thrown: non-stringifiable object`),
        value
      )
    }
  }
}

Используя этот подход, нам больше не придется обрабатывать ошибки типа unknown. Все ошибки будут представлять собой правильные Error объекты, предоставляя нам как можно больше информации и исключая риск неожиданных значений ошибок.

Обратите внимание, что E extends NormalizedError ? never не является обязательным. Однако это может помочь предотвратить ошибочную передачу объекта NormalizedError в качестве аргумента.

Чтобы безопасно использовать объект NormalizedError, нам также необходима функция защиты типа:

/**
 * Type guard to check if an `unknown` value is a `NormalizedError` object.
 *
 * @param value - The value to check.
 *
 * @returns `true` if the value is a `NormalizedError` object, otherwise `false`.
 */
export const isNormalizedError = (value: unknown): value is NormalizedError =>
  isError(value) && 'originalValue' in value && value.stack !== undefined

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

/**
 * Type guard to check if an `unknown` function call result is a `Promise`.
 *
 * @param result - The function call result to check.
 *
 * @returns `true` if the value is a `Promise`, otherwise `false`.
 */
export const isPromise = (result: unknown): result is Promise<unknown> =>
  !!result &&
  typeof result === 'object' &&
  'then' in result &&
  typeof result.then === 'function' &&
  'catch' in result &&
  typeof result.catch === 'function'

Имея возможность безопасно идентифицировать промисы, мы можем приступить к реализации нашей новой функции noThrow:

type NoThrowResult<A> = A extends Promise<infer U>
  ? Promise<U | NormalizedError>
  : A | NormalizedError

/**
 * Perform an action without throwing errors.
 *
 * Try/catch blocks can be hard to read and can cause scoping issues. This wrapper
 * avoids those pitfalls by returning the appropriate result based on whether the function
 * executed successfully or not.
 *
 * @param action - The action to perform.
 *
 * @returns The result of the action when successful, or a `NormalizedError` object otherwise.
 */
export const noThrow = <A>(action: () => A): NoThrowResult<A> => {
  try {
    const result = action()
    if (isPromise(result)) {
      return result.catch(toNormalizedError) as NoThrowResult<A>
    }
    return result as NoThrowResult<A>
  } catch (error) {
    return toNormalizedError(error) as NoThrowResult<A>
  }
}

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

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

Давайте рассмотрим, как мы можем использовать этот подход для рефакторинга кода, который мы разработали ранее:

export const doStuffV3 = async (): Promise<void> => {
  const fetchDataResponse = await fetch('https://api.example.com/fetchData').catch(toNormalizedError)
  if (isNormalizedError(fetchDataResponse)) {
    return console.log('Error fetching data:', fetchDataResponse.stack)
  }
  const fetchDataText = await fetchDataResponse.text()
  if (!fetchDataResponse.ok) {
    return console.log(
      `Unexpected response while fetching data. Status: ${fetchDataResponse.status} | Status text: ${fetchDataResponse.statusText} | Body: ${fetchDataText}`
    )
  }
  const fetchData = noThrow(() => JSON.parse(fetchDataText) as unknown)
  if (isNormalizedError(fetchData)) {
    return console.log(
      `Failed to parse fetched data response as JSON: ${fetchDataText}`,
      fetchData.stack
    )
  }
  if (
    !fetchData ||
    typeof fetchData !== 'object' ||
    !('data' in fetchData) ||
    !fetchData.data
  ) {
    return console.log(
      `Fetched data is not in the expected format. Body: ${fetchDataText}`,
      toNormalizedError(new Error('Invalid data format')).stack
    )
  }
  const storeDataResponse = await fetch('https://api.example.com/storeData', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(fetchData),
    }).catch(toNormalizedError)
  if (isNormalizedError(storeDataResponse)) {
    return console.log('Error storing data:', storeDataResponse.stack)
  }
  const storeDataText = await storeDataResponse.text()
  if (!storeDataResponse.ok) {
    return console.log(
      `Unexpected response while storing data. Status: ${storeDataResponse.status} | Status text: ${storeDataResponse.statusText} | Body: ${storeDataText}`
    )
  }
}

Вот оно! Мы решили все задачи:

  1. Типы теперь можно безопасно использовать, поэтому нам больше не нужен logError, и мы можем регистрировать ошибки напрямую, используя console.log.
  2. Область действия контролируется с помощью noThrow, как показано при определении const fetchData, который ранее должен был быть let fetchData.
  3. Вложенность сокращена до одного уровня, что упрощает поддержку кода.

Вы также могли заметить, что мы не использовали noThrow в fetch. Вместо этого мы использовали toNormalizedError, который имеет более или менее тот же эффект, что и noThrow, но с меньшим количеством вложений. Благодаря тому, как мы создали функцию noThrow, вы можете использовать ее в fetch так же, как мы использовали ее для функций синхронизации:

const fetchDataResponse = await noThrow(() =>
    fetch('https://api.example.com/fetchData')
  )

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

Вопросы производительности

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

В критичных к производительности разделах вашего кода или «горячих путях кода» всегда полезно проявлять осмотрительность при введении любых абстракций, включая утилиты обработки ошибок. Хотя современные движки JavaScript добились значительных успехов в оптимизации производительности try/catch, накладные расходы все равно могут возникнуть, особенно при чрезмерном использовании.

Рекомендации:

  1. Будьте внимательны при выборе горячих путей. Если определенный раздел вашего кода выполняется очень часто, подумайте о последствиях любых добавленных абстракций, включая обработку ошибок.
  2. Профилирование при сомнениях. Если вы не уверены в влиянии вашей стратегии обработки ошибок на производительность, воспользуйтесь инструментами профилирования, чтобы измерить и сравнить время выполнения в вашей конкретной среде.
  3. Будьте в курсе. Как всегда, будьте в курсе последних достижений в области движков JavaScript и TypeScript. Характеристики производительности могут меняться, и то, что актуально сегодня, завтра может измениться.

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

Изменения в обработке ошибок

Комбинация служебной функции noThrow и класса NormalizedError представляет собой новый подход к обработке ошибок в TypeScript, отличающийся от традиционного механизма try/catch, встроенного в JavaScript. Хотя этот дуэт предлагает упрощенную и типобезопасную обработку ошибок, разработчики должны осознавать последствия этого изменения парадигмы:

  • Отклонение от стандарта. Использование noThrow и NormalizedError представляет собой существенное отклонение от традиционной обработки ошибок JavaScript. Тем, кто привык к традиционным try/catch, возможно, придется пересмотреть свое понимание при работе в этих новых рамках.
  • Последовательность имеет решающее значение. Если вы решите использовать подходы noThrow и NormalizedError в своей базе кода, очень важно применять их последовательно. Смешение этого метода с традиционными блоками try/catch может привести к путанице и несогласованности, потенциально усложняя обслуживание и отладку.
  • Образование и адаптация. Учитывая, что ни noThrow, ни NormalizedError не являются стандартными функциями JavaScript или TypeScript, необходимо убедиться, что все члены команды хорошо разбираются в их использовании и поведении. Это может повлечь за собой создание специальной документации, примеров или даже учебных занятий, чтобы все были на одной волне.

Заключение

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

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

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

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