Все мы знаем, что языки делятся на динамические и статические: Python или JS позволяют молниеносно прототипировать, но расплачиваться нестабильностью в продакшене приходится потом, тогда как C++, Java или C# требуют прописывать типы сразу, оплачивая монументальную надежность замедлением разработки.

В 2006 году Джереми Сиек предложил разрешить этот конфликт концепцией постепенной типизации в работе «Gradual Typing for Functional Languages»: язык остается динамическим по умолчанию, однако разработчик может аннотировать типами отдельные модули или функции, не покрывая ими всю кодовую базу, а типизированный и нетипизированный код взаимодействуют через специальный динамический тип Dyn. Звучит как сказка — быстрое прототипирование с постепенным наращиванием стабильности. Однако при детальной проработке вскрылись фундаментальные нюансы: в серии последующих работ Сиек, Таха, Вадлер и другие показали, что стыковка типизированного и нетипизированного кода требует операции приведения типа (cast), а дизайн этого каста упирается в противоречие между тремя желаемыми свойствами — надёжностью системы типов (soundness), собственно постепенностью (gradual typing) и отсутствием рантайм-обёрток на границах (no wrappers).

Позднее сообщество, и я в том числе, переосмыслило третью вершину: no wrappers — это забота о производительности и прозрачности рантайма, безусловно важная, но с позиции программиста, а не математика-оптимизатора, правильнее говорить о удобстве разработчика (developer-friendly). Это свойство вбирает в себя no wrappers как частный случай, добавляя понятные сообщения об ошибках, низкий порог входа и отсутствие многослойных типовых аннотаций, ведь если ради производительности приходится городить бесконечные теоремы типов, такая система едва ли приживётся на суровом рынке опенсорса.

Так родилась трилемма Святого Грааля системы типов: выбрать можно любые два свойства, а третье неизбежно пострадает, и в этой статье мы разберём, как создатели языков пытались достичь всех трёх и на какие компромиссы шли.


❯ Три свойства, 3 пути

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

Треугольник трилеммы типов
Треугольник трилеммы типов

Soundness — созвучность, непротиворечивость

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

Самый простой пример — typescript и python с Any-типом.

from typing import Any

name: Any = "Hello"
name = name.is_integer()

Компилятор типов MyPy не покажет никаких ошибок, хотя в рантайме мы встретим AttributeError: 'str' object has no attribute 'is_integer'.

Теперь посмотрим, с чем soundness вступает в конфликт.

Путь 1 — Soundness + Developer-Friendly — теряем Gradual Typing. Если мы хотим, чтобы система была и непротиворечивой, и удобной, проще всего потребовать полной типизации. Никаких Any, никаких Dyn. Весь код аннотирован и компилятор все проверяет. Ошибки точны и понятны, потому что у компилятора есть вся информация о типах. Но увы, нельзя начать с динамического прототипа и плавно типизировать его.

Путь 2: Soundness + Gradual Typing — теряем Developer-Friendly. Это сложный путь, чтобы система типов была и постепенной, и непротиворечивой, система обязана вставлять рантайм проверки на каждой границе между типизированным и нетипизированным кодом, или строить сложные аннотации типов. Для простых типов (Dyn → Int) это приемлемо: просто проверяем, что пришло число. Но для сложных — функций, дженериков, вложенных структур — системе приходится создавать обёртки (wrappers), которые проверяют типы при каждом вызове. Эти обёртки замедляют выполнение, загрязняют стек-трейсы и нарушают структурное равенство: две идентичные функции могут перестать быть равны из-за разного количества обёрток.

С сообщениями об ошибках — отдельная муторная история. Когда обёртка ловит несоответствие типов, непонятно, кого винить: статический код, который ждал Int, или динамический, который передал String? Сиек и Вадлер разработали Blame Calculus — формализм, который отслеживает происхождение каста и в случае ошибки указывает, на какой стороне границы допущена ошибка, это мы рассмотрим чуть ниже. Таким путем идет мало кто, приходит на ум только Hack: гарантии есть, постепенность есть, но пользоваться системой типов сложно.

Gradual Typing — постепенная типизация

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

Путь 1: Gradual Typing + Developer-Friendly — теряем Soundness. Это самый популярный компромисс в нашем мире. Мы разрешаем разработчику использовать динамические типы, или не определять аннотации вообще. Компилятор доверяет программисту и не вставляет проверок на границах, и взаимодействие между этими двумя мирами происходит без накладных расходов. Правда взамен этого удобства и скорости теряется непротиворечивость, динамический тип становится квази-типом, прокси между типом и значением. Этот путь исповедуют такие языки как Python с MyPy и TypeScript (который вообще позиционируется как «синтаксический сахар над JavaScript»).

А путь Gradual Typing + Soundness с потерей Developer-Friendly мы уже обсуждали.

Developer Friendly — удобство для разработчика

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

В оригинальной трилемме эту вершину занимало свойство No Wrappers — отсутствие рантайм-обёрток на границах между типизированным и динамическим кодом, но я решил обобщить это удобством для разработчика, включив туда No Wrappers.

Оба пути мы обсудили, либо гарантии и полная типизация, либо свобода и удобство без гарантий.


Но, увы, одно из свойств всегда страдает. Любые два свойства тянут систему в определённом направлении, а третье оказывается несовместимым с этим движением, как лебедь, рак и щука.

Хотите гарантий (Soundness) и удобства без накладных расходов (Developer-Friendly)? Система должна знать всё о типах заранее — Gradual Typing невозможен.

Хотите удобства без накладных расходов (Developer-Friendly) и постепенности (Gradual Typing)? Система должна доверять разработчику и не мешать ему проверками — Soundness невозможна.

Хотите гарантий (Soundness) и постепенности (Gradual Typing)? Система должна проверять всё на границах и создавать обёртки — Developer-Friendly невозможен.

❯ Истоки: каст как источник и решение всех проблем

В 2006 году Джереми Сиек опубликовал диссертацию и статью «Gradual Typing for Functional Languages», где конкретизировал идею постепенной типизации. Он сделал центральным понятием работы динамический тип Dyn - прокси-тип между типизированным и нетипизированным кодом. Переменная типа Dyn может содержать значение любого типа, и компилятор не проверяет операции над ней. Но когда Dyn пересекает границу и попадает в типизированную функцию, системе нужна операция каста — приведения Dyn к конкретному типу, например Int.

Именно дизайн каста и порождает трилемму. Давайте возьмем псевдокод:

function add(x: Int, y: Int): Int {
    return x + y;
}

let a: Dyn = 42;
let b: Dyn = "hello";

add(a, b);

У компилятора есть три варианта, и каждый из них жертвует одним свойством:

  1. Игнорировать и ничего не проверять. Скомпилировать как есть, считать что Dyn совместим с любым типом. Быстро, удобно и никаких расходов. Но это пропускает ошибку типов в рантайм, и мы теряем soundness.

  2. Вставить проверку на границе, проверяя перед вызовом add что оба аргумента подходят по типу. Soundness соблюден в жертву developer-friendly.

  3. Либо потребовать типы сразу. Тут страдает gradual typing.

Третий и первый вариант тривиален (это просто статическая типизация или динамическая с аннотациями типов), а вот второй потребовал отдельной теоретической проработки. Сиек и Вадлер в работе «Threesomes, With and Without Blame» (2010) разработали Blame Calculus — исчисление вины. Идея в том, что каждый каст помечается меткой: положительной (static) и отрицательной (dynamic). Если каст Dyn → Int провалился, система смотрит на метку и определяет источник ошибки: положительная метка означает что виноват статический код, который ожидал Int, а получил не пойми что. А отрицательная метка говорит что виноват динамический код, который передал значение не того типа.

Формально Blame Calculus гарантирует, что ошибка будет на какой то одной стороне. Но проблема в том, что когда разработчик видит сообщение об ошибке, оно может ссылаться на каст, а не на строку которую он написал. Вот тут и начинает страдать developer-friendly.

Дальнейшие исследования, например «Exploring the Design Space of Higher-Order Casts» от Сиека и Ко., показали, что для сложных типов (функции высшего порядка, дженерики) проблема обёрток и нечитаемых ошибок только усугубляется. Каст Dyn → (Int → Int) нельзя проверить в момент приведения: функция ещё не вызвана, аргументов нет. Приходится создавать прокси-функцию, которая оборачивает исходную и проверяет типы аргументов и возвращаемого значения при каждом вызове. С каждым вложенным кастом растёт число обёрток и тем усложняется внутреннее представление программы.

❯ А что выбирают разработчики языков программирования?

Наконец-то пора перейти к тому, как реальные языки с динамической типизацией пытаются (или не пытаются) достигнуть святого Грааля.

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

На первый взгляд Python с MyPy идёт тем же путём. Any в typing ведёт себя так же, как any в TypeScript — отключает проверки. Но есть нюанс. MyPy по умолчанию строже TypeScript в двух отношениях. Во-первых, он требует явного указания Any: если вы не аннотировали переменную и не пользуетесь выводом типов, MyPy может потребовать аннотацию в зависимости от настроек. Во-вторых, mypy можно настроить на строгую проверку, запретить неявное использование Any. TypeScript такого уровня строгости не предоставляет.

Но плата за такою настраиваемость — сложность настройки. Чтобы получить soundness, нужно не просто писать аннотации, но и правильно сконфигурировать сам тайпчекер, разобраться в флагах, интегрировать его в CI, а также может порождать монструозные аннотации типов. Как по мне, overload и дженерики в питоне — это монструозные конструкции. Это смещает баланс в сторону Developer-Friendly страдает, даже если Soundness в теории достижима. Один только typing.overload заставляет меня дрожать в ужасе. Например, в typeshed достаточно долго висят issue связанные с overload, например этот, я сам его пытался решить, но окончательно запутался в куче функций, типов и необходимости писать по несколько overload’ов в определенном порядке.

Команда Elixir в версии 1.20.0 предложила другой взгляд. Вместо того чтобы жертвовать каким нибудь свойством, они решили пойти на компромиссы, чтобы сохранить все три свойства. Dynamic-типы, сужение типов и многое другое, полноценный разбор этого релиза я планирую в отдельной статье, ибо он интересный даже для тех кто никогда не интересовался миром BEAM, а пока можете прочитать оригинальную статью.

❯ Заключение

Трилемма Святого Грааля — не формальная теорема. Никто не доказал математически, что система с тремя свойствами невозможна. Это наблюдаемый на практике фундаментальный конфликт, который воспроизводится в любой попытке скрестить динамику со статикой.

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

Тем не менее, поиски продолжаются. Каждые несколько лет появляются новые подходы — от расширенного вывода типов до множественных представлений (set-theoretic types, которые кстати также изучаются в elixir), которые как раз и пытаются переосмыслить святой Грааль.

Выбирая язык для проекта, вы всегда выбираете сторону треугольника, даже если не формулируете это явно. Быстрый прототип без типов с перспективой роста? Ваша плата — отсутствие гарантий на ранних этапах, и TypeScript или Python с Any здесь — осознанный выбор. Большой проект, где надёжность критична, а скорость разработки вторична? Возможно, вы заплатите удобством и возьмёте Hack или строгую конфигурацию MyPy. Готовы отказаться от постепенности вообще? Rust или Haskell дадут и soundness, и отличные сообщения об ошибках.

И как по мне, интересны сейчас компромиссы, как можно расширить рамки, соблюдая все три свойства одновременно? Тут я поддерживаю авторов Elixir.


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

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


  1. Dhwtj
    29.07.2026 09:59

    Типы нужны не только для проверок compile time, а ещё и чтобы проектировать в типах, типы (утверждения о свойствах данных) появляются до кода и это правильно.

    Но при постепенной типизации это невозможно.


    1. youngmysteriouslight
      29.07.2026 09:59

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

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

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


  1. zgwerby
    29.07.2026 09:59

    Ну да, тип - чёткий контракт о содержимом.
    Если нужно преобразовать тип в тип, то у нас есть явные reinterpret() для тех типов, которые отличаются лишь интерпретацией бит или код конвертации для остального. Это правильно.

    Никогда не понимал, всего этого извращения с постепенной типизацией/


    1. Dhwtj
      29.07.2026 09:59

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

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


  1. Ciyoxe
    29.07.2026 09:59

    По-моему, постепенная типизация вообще не нужна. Нисколько не ускоряет она прототипирование, потому что даже языки с динамической типизацией всё равно имеют типы и их приходится держать в голове. Апи и интерфейсы всё равно имеют соглашения, которые приходится учитывать. Мы просто перекидываем ту же самую работу между явным и неявным видами, т.е. получаем бОльшую когнитивную нагрузку ради нулевого профита

    Единственное, когда она "пригождается" - переписывание нетипизированного легаси, попутно добавляя типы


    1. Dhwtj
      29.07.2026 09:59

      Здешние отзывы единогласны: постепенная типизация это ошибочная идея.

      Формулировка 2006 года не сбылась; выжила ослабленная версия, которая честно называлась бы "опциональный статический линтер".

      Реальную скорость питону дают не отсутствие типов, а: REPL и отсутствие цикла компиляции, батарейки, лаконичный синтаксис, нулевой церемониал: не надо объявлять интерфейсы, дженерики, DI. Это сложные церемонии, которые были свойственны Java/C++ образца 2005 года, и такое ускорение разработки ошибочно записали в заслугу динамической типизации. Современные статические языки с выводом типов (Rust, TS) показали: аннотаций ты пишешь мало, а проверку получаешь. Дихотомия "типы = медленно писать" это артефакт старой эпохи.

      И есть ловушка.

      Быстрый динамический прототип активно использует возможности, враждебные типизации: словари вместо структур, monkey-patching, метаклассы, **kwargs через три слоя. Поэтому обещанное "потом добавим типы" превращается не в аннотирование, а в археологию, то есть восстановление модели из кода, что дороже, чем спроектировать её сразу. Особенно, когда автор забыл модель или ушёл из проекта. Скорость прототипа взята в дорогой кредит под будущую типизируемость.


  1. youngmysteriouslight
    29.07.2026 09:59

    Добротная статья.

    Лично я считаю, что непротиворечивость (soundness) понимается слишком строго. Вариант Soundness + Developer-Friendly описан как согласованность статической (которая проверяется до запуска) типизации и динамических (которые проверяются во время работы) контрактов, хотя механизм контрактов является плюс-минус независимым механизмом (Грис «Наука программирования», Хоар, Финдлер и пр.), который может в какой-то степени соотноситься с семантикой той или иной системы типов (doi:10.1145/3022670.2951930, doi:10.1016/j.procs.2018.11.069 или по-русски)

    О blame как частной задачи отладки

    Между тем, маленький оффтоп, Вадлеровский blame и git blame решают в сущности одну задачу — поиск причины, по которой возникла несогласованность, причём причина может лежать в коде, который в своём контексте (контракт на входные данные, коммит) выглядит безобидно и согласованно. По моему мнению эта задача является подзадачей глобальной задачи отладки и методы решения должны быть универсальными с точки зрения теоретической проработки, подобно тому, как есть теория конструкций языков программирования, теория структур данных, теория концептуального моделирования и т.п. Я не знаю, что на этот счёт уже придумано.

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

    Впрочем, сделав такое, мы напрочь отключаем сборщик мусора и прощай-прощай память.

    Что такое Developer-Friendly

    В работе doi:10.1145/3708981 предлагается метод «Рациональный программист» для оценки уровня Developer-Friendly. Это к вопросу автора о том, как можно формализовать этот аспект.

    Все предыдущие комментарии содержали вопрос, зачем вообще нужна последовательная типизация. Нужна она не для того, чтобы один программист мог в рамках одного фрагмента своего кода свободно переключаться между «тут я хочу строгости и гарантий» и «тут я художник, я так вижу». Одна из причин названа @Ciyoxe: переписывание нетипизированного легаси, попутно добавляя типы. А ещё она нужна для сопряжения нетипизированного кода, который уже написан и, возможно, будет меняться в будущем, за который наш лирический программист не отвечает, и типизированного кода, который наш программист желает писать по уму.

    Пример такой ситуации: как использовать JavaScript-пакеты из репозитория npm в типизированных проектах, которые компилируются в JS и исполняются в браузере? Очевидно, типизированный язык программирования должен предлагать средства для сопряжения нетипизированного кода библиотеки и типизированного кода самой программы. Причём далеко не всегда все импортируемые функции можно адекватно типизировать средствами самого языка.

    Кстати, проблему можно распространить на более широкий класс: как переиспользовать код на языке с более «плохой» системой типов в проекте, который написан на более «хорошей» системе типов? Собственно, эта задача и решается постепенной типизацией.


  1. vkni
    29.07.2026 09:59

    Мне в своё время объяснили, что у soundness в английском в данном случае берётся неосновное значение. У слова sound в переводах, в словаре вы увидите

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

    И видите, что там в самом конце? Так вот в программистском смысле soundness — это именно значение из самого конца — крепкость, цельность. В смысле building soundness.


    1. vkni
      29.07.2026 09:59

      За статью огромное спасибо! Я как раз пол года назад вспоминал gradual typing, и недоумевал, чем дело закончилось. А руки посмотреть как-то не доходили.