Команда Spring АйО не могла остаться в стороне и не прокомментировать одну из самых обсуждаемых новинок Kotlin, анонсированную на KotlinConf 2025 — Rich Errors. Вместо того чтобы выбрасывать исключения, теперь функции могут возвращать возможные ошибки как часть своей сигнатуры:

fun fetchUser(): User | NetworkError

Такой подход делает потенциальные сбои явными, упрощает тестирование и избавляет от try-catch для предсказуемых ошибок. Новинка уже доступна в Kotlin 2.4 и, по мнению авторов, особенно полезна в бизнес-логике.

Но в сообществе мнения разделились.

Что говорят сторонники Rich Errors?

  • Это логичное продолжение идеи безопасности типов, как null safety.

  • Ошибки становятся частью API — теперь явно видно, какие именно проблемы могут возникнуть.

  • try-catch больше не нужен там, где ошибка — ожидаемый результат.

  • Тестировать становится проще: вместо моков и исключений — обычная проверка значения.

  • Хорошо сочетается с функциональными паттернами без необходимости подключать сторонние библиотеки.

Что вызывает сомнения?

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

  • Нет способа элегантно агрегировать ошибки: A | B | C работает, но не имеет общей семантики.

  • В рамках Spring-приложений реалистичная польза ограничена — фреймворки останутся на исключениях.

  • Добавление такого типа обработки может серьёзно сказаться на времени компиляции.

И что теперь?

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

А вы как думаете? Используете ли Rich Errors в своём коде — или пока просто наблюдаете со стороны?


Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано

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


  1. ExTalosDx
    25.07.2025 14:24

    Spring могут и доработать.

    Нет смысла спешить с мнением, кмк.

    С моей точки зрения это более явный и более удобный optional. Только в разрезе не только null safety.

    Так же могу накинуть минус: кто-то может сказать что это checked exception 2.0.

    Потому что позволяет одновременно проверять насколько ошибок в более удобном "try-catch (when)".

    Но для меня это плюс, хоть и тоже самое, но читать такой код как в примере на презентации крайне приятно.


    1. Anarchist
      25.07.2025 14:24

      Нет, это совсем не checked exceptions. Это попытка уйти от исключений в принципе. Кроме того, написать лямбда функцию с checked exception невозможно. А с такой возвращаемой ошибкой - пожалуйста. Исключения - одна из родовых травм Java, и всем JVM-языкам приходится возиться с ними, к сожалению.


      1. rsashka
        25.07.2025 14:24

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


  1. ETOZHEKIM
    25.07.2025 14:24

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


    1. KivApple
      25.07.2025 14:24

      Должна быть функциональная обвязка, как map/map_err в Rust. Тогда можно будет строить цепочки обработчиков.


      1. Anarchist
        25.07.2025 14:24

        И в Rust, и в Scala (откуда, скорее всего и пришел такой синтаксис вариантов возвращаемых значений) есть даже более красивая конструкция - pattern matching. Я имею в виду функциональный, а не тот, который из регекспов. :) Плюс декомпозиция. Всё это в Котлине вроде уже есть.


    1. gBear
      25.07.2025 14:24

      Не удобно, что они возвращаются функцией, ...

      ?! Как раз - наоборот. В кои-то веки, "обшибки" - по честному - часть сигнатуры ф-ции. С нормальной композицией, выводом и т.п.

      Если не понятно - User | NetworkError из примера - это полноценный тип. Эдакий AA union type, с единственным ограничением: AA это исключительно для подтипов Error.

      Оно - может быть - и не универсально. Зато - сильно утилитарно получается. Как в Haskell - может быть и хочется, но не получится по очевидным причинам. А как в Scala - ну мы видели, к чему "оно" приводит :-)

      ... обработка будет выглядеть странно

      Да ладно?! Даже самый очевидный (но не единственный) вариант обработки - через when выглядит очень органично.

      В try catch ты получаешь явно объект ошибки, думаю поэтому его и придумали.

      Тут у нас отдельный высший тип для ошибок - Error. Т.е. они ("обшибки") - by design - не пересекаются с подтипами Any? - куда уж явней "объект ошибки" получать?

      Единственное преимущество Throwable - это наличие stacktrace. Но и тут - если оно нам таки надо - всегда можно "раскрутиться" через !! оператор.

      Как будто это шаг назад

      В каком месте?!


      1. Dhwtj
        25.07.2025 14:24

        А как в Scala - ну мы видели, к чему "оно" приводит :-)

        Я не видел. И к чему же?


        1. gBear
          25.07.2025 14:24

          Если коротко - каноничный AA-union-type штука сильно уж - скажем так - "обобщающая". Во всех смыслах этого слова.

          И очень уж много нужно контроля (на уровне "усилий мозга"), чтобы пользоваться этим "правильно". А чуть "отвлекся" - и всё... "получилась какая-то нечитаемая хрень" :-(

          И ещё больше усилий нужно для возврата в "контекст". Читать "это", порой, совсем "грустненько"... хотя и сам "это вчера только написал" :-)


          1. Anarchist
            25.07.2025 14:24

            А можно пример "нечитаемой хрени"?


            1. Dhwtj
              25.07.2025 14:24

              Видимо, такое

              // Используем стандартный тип Either[A, B] для представления "или-или".
              // Договоримся, что слева (Left) - ошибка, справа (Right) - успех.
              // А как представить "Загрузку"?... Уже проблема. 
              // Ну, допустим, обернем всё в Option.
              type WebRequestResult[T] = Option[Either[String, T]]
              
              // None => Загрузка
              // Some(Left("...")) => Ошибка
              // Some(Right(data)) => Успех

              Использование

              def printResult(result: WebRequestResult[Int]): Unit = {
                result match {
                  case None => println("Загрузка...")
                  case Some(either) => either match {
                    case Left(msg)   => println(s"Ошибка: $msg")
                    case Right(data) => println(s"Успех! Данные: $data")
                  }
                }
              }

              один разработчик использует "каноничный" sealed trait, другой — Either, а третий придумывает свою структуру на tuple'ах. Все три подхода работают, но вместе превращают кодовую базу в ту самую "нечитаемую хрень


          1. Dhwtj
            25.07.2025 14:24

            Ну и кстати, в rust наиболее канонично видимо так

            use std::path::PathBuf;
            
            // Ошибка #1: не удалось прочитать конфиг
            #[derive(Debug)] // для вывода
            pub struct ConfigError {
                pub path: PathBuf,
                pub reason: String,
            }
            
            // Ошибка #2: не удалось подключиться к БД
            #[derive(Debug)]
            pub struct DbError {
                pub host: String,
                pub port: u16,
                pub message: String,
            }
            
            // Функции, которые их возвращают (заглушки)
            fn load_config() -> Result<(), ConfigError> { /* ... */ Err(ConfigError { path: "cfg.toml".into(), reason: "permission denied".into() }) }
            fn connect_db() -> Result<(), DbError> { /* ... */ Ok(()) }

            Использование

            use anyhow::Result;
            
            fn run_anyhow() -> Result<()> {
                load_config()?;
                connect_db()?;
                Ok(())
            }
            
            fn main() {
                if let Err(e) = run_anyhow() {
                    // e - это `anyhow::Error`, который хранит исходную ошибку внутри
                    println!("Ошибка: {}", e);
            
                    // При желании, можно добраться до исходного типа
                    if let Some(config_err) = e.downcast_ref::<ConfigError>() {
                        println!("Проблема с конфигом: {}", config_err.path.display());
                    }
                }
            }


  1. Dhwtj
    25.07.2025 14:24

    Теперь почти как в rust))

    Интеграция с фреймворками: Самый веский практический аргумент. Этот подход лучше всего работает в ядре бизнес-логики, изолированном от фреймворка. На границе (в контроллерах) пишется адаптер: when (result) { is Ok -> ..., is Err -> mapToHttpError(...) }. Пытаться тянуть Result до самого Spring MVC — плохая идея


    1. gBear
      25.07.2025 14:24

      Пытаться тянуть Result до самого Spring MVC — плохая идея

      "Прикол" в том, что тут как раз нет Result'а. Ни в терминах rust'а, ни в терминах kotlin'а.

      Result - это "эвфемизм" более общего Either. А Either - by design - скажем так, "хромает" в плане композиции "левой" своей части. Т.е. "левая" часть композиции выводится в что-то осмысленное только при прям очень сильных ограничениях, накладываемых на. Чего - по понятным причинам - делать вообще не хочется.

      Result - соответственно - "хромает" на "правую" свою часть.

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

      Насколько - в реальности - окажутся сильными ограничения, накладываемые на подтипы Error - это будем делать посмотреть (с). Дизайн пока не финализирован. Но то, что есть сейчас не выглядит сколь-нибудь "страшным".

      Я к тому, что иметь - условный -

      private val <T:Any> (T|Error).responseEntity: ResponseEntity<out Any> = 
        get() -> when { ...}
      

      на уровне контроллера (или даже его пакета) - не выглядит, имхо, чем-то прям "ужос-ужос". Error и upper-bound - понятно, "в реальности" заменяются на какие-то более осмысленные type aliase'ы.

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


      1. Dhwtj
        25.07.2025 14:24

        Да, в этом смысле удобнее.

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


      1. moonster
        25.07.2025 14:24

        За последние несколько лет я запилил десятки контроллеров. По итогу пришел как раз к примерно такой схеме - сервис, который обслуживает контроллер, всегда возвращает Either. Может, конечно и исключение бросить, но это всегда 500.

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


  1. gBear
    25.07.2025 14:24

    Сугубое имхо...

    Выглядит оно уже сейчас сильно "вкусно". Ребята из arrow - уже облизываются :-) Наконец-то нормальная "человечья" error composition "из коробки".

    Озвученные "сомнения" - в разрезе Kotlin - выглядят, мягко говоря, неубедительно.

    Яб таки дождался финализации. Возможно, оно действительно "негативненько" скажется на interop'е со стороны Java. Возможно (ну а вдруг), придумают как таки обойтись - в этой части - без "костыликов". Но это пока единственное, что хоть сколько-то "пугает".


  1. ilja903
    25.07.2025 14:24

    Какой-то недо typescript. Тулбокс для всего этого маленький, зачем возвращать ошибку если можно было бы возвращать разные классы например. Типов нет, а семантика как будто есть. Котлин стал не лучшей джавой, а недо сишарпом.


  1. ris58h
    25.07.2025 14:24

    Есть где-нибудь нормальное краткое описание фичи? 45-минутное видео смотреть не горю желанием.

    Не понял как "кидается" ошибка. Через throw или через return? Могу ли я создать функцию, которая возвращает и кидает ошибку того же класса одновременно? Если да, то как понять был ли это успешный возврат или ошибка?


    1. AWE64
      25.07.2025 14:24

      перебирай типы, в котлине не в моде полиморфизм. И не задавай лишних вопросов, а радуйся, радуйся!


    1. gBear
      25.07.2025 14:24

      Есть где-нибудь нормальное краткое описание фичи?

      слайды по докладу

      Не понял как "кидается" ошибка. Через throw или через return?

      throw никак не меняется. Соответственно, не имеет смысла для подтипов Error. Т.е. "ошибка" возвращается штатно - через return.

      Могу ли я создать функцию, которая возвращает и кидает ошибку того же класса одновременно?

      Нет. Но если очень хочется, через !! можно кинуть KotlinException, который "обернет" Error.

      Если да, то как понять был ли это успешный возврат или ошибка?

      Все "ошибки" - это подтипы Error. Error - это новый отдельный top type. Т.е. и он, и его подтипы не приводятся к Any?. Соответственно, на "или ошибка" можно проверять, банально, через is Error.


  1. Spyman
    25.07.2025 14:24

    Ну т.е. аналог того, что мы влзващали бы ошибку или результат как варианты sealed класса, только теперь меньше кода надо писать, если ошибки одинаковые в разных методах. В целом неплохо, но и не то, чтобы прямо интересно. У exception уникальная механика пробрасывания ошибки вверх по цепочке вызовов и finaly гарантирующий выполнение, а тут по сути просто синтаскический сахар.