Урок 7: Тип never и отличия от void в TypeScript

На 7-ом уроке мы разберем тип, который часто вызывает вопросы у новичков, но становится мощным инструментом для написания надежного кода. Это тип never и его отличия от void.

Что такое тип never и зачем он нужен?

Давай начнем с самого главного, с определения. Тип never в TypeScript представляет тип значений, которых никогда не возникает. Прочитай это предложение еще раз. Это не просто «что-то, что мы не используем» или «отсутствие значения». Это фундаментальное понятие, которое говорит компилятору: «В этой точке кода выполнение функции не завершится нормально. Сюда мы никогда не дойдем».

Ты можешь спросить: «Максим, зачем мне нужно описывать то, чего никогда не случится?». И вопрос будет абсолютно законным. Ответ кроется в одной из главных целей TypeScript, статистическом анализе кода и выводе типов. Компилятор постоянно строит граф возможных путей выполнения твоего кода и проверяет их на типобезопасность. Тип never это краеугольный камень в этой системе. Он позволяет компилятору точно знать, что определенные ветки кода недостижимы, что делает проверки более строгими, а твой код более предсказуемым.

Представь себе охранную систему в здании. never это как датчик, который срабатывает не тогда, когда кто-то прошел, а тогда, когда кто-то прошел через стену. Это событие, которое по логике вещей невозможно и система должна на него отреагировать. В нашем случае «отреагировать» выдаст ошибку компиляции или наоборот, корректно сузить типы в других частях кода.

В каких ситуациях тип never появляется сам по себе?

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

Первый и самый очевидный сценарий это функция, которая всегда выбрасывает исключение. Как только выполняется оператор throw, нормальное выполнение функции прекращается. У нее нет шансов вернуть какое-либо значение.

typescript
function throwError(message: string): never {
    throw new Error(message);
}

// Компилятор понимает, что результат вызова этой функции нельзя присвоить ни одному типу, кроме never
let result: never = throwError("Всё сломалось!");
// А вот это уже вызовет ошибку компиляции:
// let numberResult: number = throwError("Попытка присвоить never числу"); // Ошибка!

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

typescript
function infiniteLoop(): never {
    while (true) {
        console.log("Бесконечно вечный...");
        // Здесь нет break или return, цикл действительно бесконечный
    }
}

Третий сценарий, более продвинутый, но очень важный, исчерпывающие проверки (exhaustiveness checks) в конструкциях switch или цепочках if-else. Это тот случай, где never показывает свою настоящую силу для повышения надежности кода.

Допустим, у нас есть тип объединение (union type) Status, который может принимать одно из трех значений. Мы пишем функцию-обработчик для каждого статуса.

typescript
type Status = "success" | "error" | "pending";

function handleStatus(status: Status) {
    switch (status) {
        case "success":
            console.log("Успех!");
            break;
        case "error":
            console.log("Ошибка!");
            break;
        case "pending":
            console.log("В процессе...");
            break;
        default:
            // Готово
            let unexpectedStatus: never = status;
            throw new Error(`Неизвестный статус: ${unexpectedStatus}`);
    }
}

Сейчас в default мы присваиваем status переменной типа never. Это безопасно, потому что компилятор знает, что к моменту выполнения default все возможные варианты для Status уже были обработаны, а значит, status теоретически не может иметь никакого значения и его тип внутри default — never. Это работает без проблем.

Но что произойдет, если мы расширим наш тип Status, добавив новое значение, например, "rejected"?

typescript
type Status = "success" | "error" | "pending" | "rejected"; // Добавили новый статус

function handleStatus(status: Status) {
    switch (status) {
        case "success":
            console.log("Успех!");
            break;
        case "error":
            console.log("Ошибка!");
            break;
        case "pending":
            console.log("В процессе...");
            break;
        // Забыли обработать case "rejected"
        default:
            // Теперь компилятор видит, что в status может прийти "rejected", тип которого string.
            // Мы пытаемся присвоить string (возможный тип status) переменной с типом never.
            let unexpectedStatus: never = status; // ОШИБКА КОМПИЛЯЦИИ!
            // Тип 'string' не может быть присвоен типу 'never'.
            throw new Error(`Неизвестный статус: ${unexpectedStatus}`);
    }
}

Компилятор укажет на ошибку! Он говорит нам: «Эй, дружище, твоя проверка не является исчерпывающей! Ты добавил новый статус, но не обработал его в switch, поэтому теперь в default может прийти значение, а это небезопасно». Это невероятно мощный механизм, который заставляет тебя явно обрабатывать все возможные случаи, предотвращая ошибки в рантайме. По сути, мы переносим потенциальную ошибку из времени выполнения в время компиляции, когда ее исправить гораздо дешевле и проще.

Что такое тип void?

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

Классический пример, функция, которая что-то выводит в консоль или меняет состояние DOM-дерева.

typescript
function logMessage(message: string): void {
    console.log(message);
    // Здесь нет return, поэтому возвращаемое значение undefined.
    // Но тип именно void, а не undefined, потому что это семантически другое.
}

function updateDOMElement(element: HTMLElement, text: string): void {
    element.innerText = text;
    // Функция завершилась успешно, свою работу сделала, значения не вернула.
}

Важное техническое отличие: в JavaScript функция без return возвращает undefined. TypeScript для подобных случаев использует тип void, чтобы явно разделить семантику «функция возвращает undefined как значение» (что можно типизировать как (): undefined) и «функция не предназначена для возврата значения» ((): void).

Ключевые различия между never и void

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

  1. Семантика (Суть):

    • void: Функция завершилась нормально, но не вернула значимое значение. Выполнение кода продолжается после вызова этой функции.

    • never: Функция не завершилась нормально. Выполнение кода никогда не продолжится после вызова этой функции. Она либо сломала программу (выбросила исключение), либо ушла в бесконечность (зависла в цикле).

  2. Возвращаемое значение:

    • void: Функция на самом деле возвращает undefined при выполнении в JavaScript.

    • never: Функция ничего не возвращает в принципе. У нее нет конечной точки выхода.

  3. Использование в типах:

    • void: Переменная типа void может быть присвоена только undefined или null (если не включен strictNullChecks). Но в основном он используется для указания типа возвращаемого функцией значения.

    • never: Тип never является подухом (subtype) всех типов в системе TypeScript. Это значит, что его можно присвоить куда угодно: let x: number = throwError();. С точки зрения типобезопасности это допустимо, потому что ошибка выбросится и присвоение x никогда не произойдет. Однако, никуда нельзя присвоить значение типа never (кроме самого never). Он стоит на вершине иерархии как «ничто».

  4. Поведение в объединениях (Union Types) и пересечениях (Intersection Types):

    • never в объединениях исчезает. string | never упрощается до string. Потому что объединение с «ничем» не меняет исходный тип.

    • never в пересечениях «поглощает» другие типы. string & never упрощается до never. Потому что невозможно найти значение, которое одновременно является и string и never.

    • void ведет себя как обычный тип в этих операциях. string | void это корректный union-тип. string & void это пересечение, которое на практике почти никогда не встречается.

Практические примеры и задачи для закрепления

Давай перейдем от теории к практике. Чтобы закрепить материал, я приготовил для тебя несколько задач и примеров. Обязательно попробуй их выполнить самостоятельно в своей IDE или в TypeScript Playground.

Задача 1: Фабрика ошибок
Напиши функцию createError, которая принимает код ошибки (число) и сообщение (строку). Функция должна бросать исключение, созданное из этих данных. Явно укажи для функции возвращаемый тип never. Убедись, что компилятор правильно определяет тип.

typescript
// Твое решение:
function createError(code: number, message: string): never {
    throw { code, message };
}

// Проверка:
console.log("Это сообщение не будет выведено"); // Компилятор может даже подсветить эту строчку как недостижимую после вызова функции ниже?
createError(404, "Not Found");
console.log("И это тоже");

Задача 2: Бесконечный обработчик
Представь, ты пишешь программу для сервера, который должен слушать входящие подключения в бесконечном цикле. Создай функцию startServer, которая внутри содержит while (true) и логирует сообщение «Сервер слушает на порту 3000» каждые 5 секунд. Тип функции укажи как never.

typescript
// Твое решение:
function startServer(): never {
    while (true) {
        console.log("Сервер слушает на порту 3000");
        // Для задержки используй setTimeout, но помни, что это не остановит цикл
        // Эта задача больше про типы, чем про асинхронность :)
        // В реальности так, конечно, не делают из-за блокировки event loop.
        const waitUntil = new Date().getTime() + 5000;
        while (new Date().getTime() < waitUntil) {}
    }
}

// Вызов функции:
// startServer();
// console.log("Сервер запущен?"); // Эта строка никогда не выполнится

Задача 3: Исчерпывающая проверка (Exhaustiveness Check)
Дан тип Shape и функция calculateArea, которая вычисляет площадь для каждой фигуры. Добавь новую фигуру Triangle в тип Shape. Твоя задача добиться, чтобы компилятор TypeScript указал на ошибку в блоке default, тем самым заставив тебя обработать новый case.

typescript
type Shape = 
  | { kind: "circle"; radius: number }
  | { kind: "square"; side: number }
  | { kind: "rectangle"; width: number; height: number };
  // | { kind: "triangle"; base: number; height: number }; // Раскомментируй эту строку

function calculateArea(shape: Shape): number {
    switch (shape.kind) {
        case "circle":
            return Math.PI * shape.radius ** 2;
        case "square":
            return shape.side ** 2;
        case "rectangle":
            return shape.width * shape.height;
        default:
            // Добейся, чтобы здесь была ошибка компиляции после добавления Triangle
            const _exhaustiveCheck: never = shape;
            return _exhaustiveCheck;
    }
}

// После того как ты добавишь Triangle и получишь ошибку, допиши обработку для треугольника:
// case "triangle":
//     return 0.5 * shape.base * shape.height;

Распространенные ошибки и лучшие практики

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

  • Лучшая практика: Всегда используй never для функций, которые гарантированно бросают исключения или входят в бесконечный цикл. Это делает твои намерения явными для компилятора и других разработчиков.

  • Лучшая практика: Обязательно применяй технику исчерпывающих проверок с never в default блоках switch при работе с union-типами. Это твой надежный страж, который не даст тебе пропустить добавление новой логики при расширении типов.

  • Ошибка: Путать void и never в типах колбэков. Если колбэк не должен быть прерван исключением, используй void. Если же колбэк может бросить исключение (и это часть его логики), то его тип, скорее всего, будет () => void, а не () => never, так как бросок исключения это только один из возможных сценариев.

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

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

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий