DevGang
Авторизоваться

Правильная обработка ошибок в TypeScript

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

  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)
}

Этот подход, вероятно, работает для 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 устанавливает unknown тип бросаемых объектов? На первый взгляд это может показаться редким крайним случаем, и приведение типов может показаться разумным решением. Однако это еще не все. Хотя undefine и null являются наиболее разрушительными случаями, поскольку они могут привести к сбою вашего приложения, могут быть выброшены и другие значения. Например:

try {
  throw false
} catch (error) {
  console.log((error as Error).message)
}
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, поскольку оно содержит больше информации, чем свойство сообщения. Однако 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 демонстрирует мощь современных парадигм программирования. Эти инструменты позволяют разработчикам изящно обрабатывать как синхронные, так и асинхронные ошибки, гарантируя, что приложения останутся устойчивыми к неожиданным проблемам.

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

Источник:

#TypeScript
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться