Когда я был начинающим, я мог писать простые приложения на C# и C++. Долго игрался с консольными прогами, пощупал десктопные, и в какой-то момент захотел сделать сайт. Меня ждал большой сюрприз — чтобы делать сайты, одного сишарпа мало. Надо ещё знать жс, хтмл, цсс и прочую фронтовую хрень. Я потратил около недели на эти вещи, и понял — не мое. Я мог написать какой то код на джаваскрипт, но он не содержал типов, и я никак не мог взять в толк — как к этому вообще подходить. Это какое-то игрушечное программирование. Ну и забросил к чертям.
Уже потом, работе на третьей, меня перевели в отдел, где делали веб. Я подумывал уволиться, но мне объяснили — там тайпскрипт, тайпскрипт — это такой сишарп для браузера.
Я согласился, изучил его, и сейчас это один из моих любимых ЯП. Но. Тайпскрипт — это вот вообще не сишарп. Это язык с принципиально другой системой типов. Сложной, мощной, но другой.
Самих параметров типизаций несколько.
Есть статическая/динамическая типизация. Статическая — это когда типы данных известны на этапе компиляции. Компилятор знает типы переменных, и на их основании проверяет корректность программы. Динамическая — это когда типы известны только на стадии выполнения, и компилятор при сборке не проверяет ничего.
При этом все статически типизированные ЯП, которые я использовал, дают возможности для динамической типизации. Any в TS, Dynamic в сишарпе. В конце концов тот же тип Object — по идее, я запихиваю в него все что угодно, и говорю, что у меня статически типизированный код. Но строго говоря, это не совсем так. Потому что я могу принять Object извне, и покастить его к чему угодно, не делая никаких проверок — это ли не элемент дин типизации?
Я не знаю, делает ли язык более динамическим возможность динтипизировать куски кода. Если VSCode статически чекает мой js код, в котором даже определений типов нет — делает ли это джаваскрипт статически типизированным? А если да, то как тогда это ужать в бинарный параметр?
А ведь это для меня самое важное — я не пишу код на динамически типизированных ЯП, потому что хочу, что бы компилятор работал за меня.
Когда я начинал писать на тайпскрипте, я знал, что у него статическая типизация. Тогда я не понимал, что это значит, и думал что статическая — это как в сишарпе. Я называл её строгой. Я писал свой код на ts, и не понимал — какого хрена происходит. Почему я объявил тип
type Lead = {
id: string
}
сделал себе функцию, которая с ним работает — и этой функции можно скормить вообще все, у чего есть такое же поле. Я объяснил себе это очень просто — тайпскрипт дерьмовый. В нем плохая типизация. Со мной работали опытные коллеги, и я вывалил на них негодование — пацаны, ну что за дерьмо. Пацаны объяснили мне, что у ts статическая типизация, но не номинативная. Я послушал их, погуглил и узнал, что есть номинативная/структурная типизация.
Номинативная — это когда для нас значимо имя типа, его номинал. А структурная — это когда мы проверяем типы не по именам, а по их свойствам. То-есть, если у меня есть вот такие классы
class A {
string name;
}
class B {
string name;
}
То для компилятора (или среды исполнения) они будут разными типами. А при структурной типизации между двумя этими типами нет никакой разницы. Это, как мне кажется, самый сложный момент. Я точно не могу сказать однозначно, какая модель мне подходит больше.
Дело в том, что теоретически номинативная даёт больше гарантий. Но это сложные гарантии. Если я написал функцию, которая работает с классом А из примера, то и с инстансом класса B она отработает корректно — в том смысле, что она не свалится на этапе исполнения с ошибкой. Поэтому, когда я программирую на языке со структурной типизацией, я пишу функцию, и говорю — вот она работает с чем угодно, у чего есть вот такие поля или методы.
Такой подход помогает писать меньше бойлерплейта, и писать более гибкие абстракции. С архитектурной точки зрения, ты можешь подружить два больших модуля, которые изначально не были спроектированы на совместную работу — и при этом компилятор сможет верифицировать их корректность — это очень круто.
Проблема в том, что есть много случаев, когда мне в коде требуются именно номинативные проверки. Например, у меня есть одинаковые по структуре сущности Customer и Client, и я хочу написать метод, который сохраняет клиента в базу. Если у меня структурная типизация, пользователь моего кода берет, и скармливает моему методу кастомера — код отрабатывает, ошибка возникает, но я о ней узнаю только в багрепорте через полгода работы кода на проде. Предсказать последствия трудно, но легко сообразить — если такого можно избежать, надо это сделать.
Я прочитал книжку по окамлу, и стал всем рассказывать, что это крутой язык со структурной типизацией. Но пришли умные дядьки, и объяснили мне, что в окампле просто есть структурно типизированные типы данных, это не делает его структурно типизированным. Я с ними не согласен, но по большому счету, с собой я тоже не согласен.
Я так до конца и не понял несколько важных моментов. Во первых, вот структурная типизация — она же может проверять типы на полное соответствие и на достаточное. Допустим у меня есть такой код
type A = {
id: string
}
type B = {
id: string;
name: string;
}
function test(a: A) {}
Тайпскрипт разрешит мне передать в test инстанс B — потому что его структура достаточна с точки зрения типа А. Но по идее, я ведь могу захотеть такую модель типизации, которая будет структурной, но при этом не разрешит считать B удовлетворяющим A. Я не знаю, есть ли такая типизация хоть где-то, нужна ли она, и как бы она называлась.
Причем теоретически, я могу заставить тайпскрипт валидировать полное соответствие — как тогда мне называть его модель типизации? Понятия не имею.
Мой друг Паша как то сказал мне, что в тайпскрипте типизация — дерьмо. Я ответил, что в тайпскрипе я могу написать тип, который описывает число меньше ста, и получу компайл тайм гарантию, что число было проверено. А в сишарпе я так сделать не смогу. Паша впечатлился. Но он тоже прав — в куче кейсов типизация в ts — дырявая.
Есть сильная/слабая типизация. Её легко спутать со структурной/номинативной, но это разные вещи. Этот параметр определяет, делает ли среда исполнения неявные касты из одного типа в другой. Я не скажу за все япы, но все, на которых писал код я, иногда делают такие касты. То есть я не знаю ни одного абсолютно сильно типизированного языка программирования, и поэтому, когда мы говорим "слаботипизированный" — мы имеем в виду такой, который делает такие касты до неприличия часто.
Понятие очень размытое, например считающийся строгим сишарп позволяет программисту описать имплиситный каст например Собаки к инту. За 7 лет моей работы с дотнет стеком я ни разу не встречал кода, который дефайнил бы имплиситные касты — к чести моих коллег — но сам язык это позволяет.
Для меня этот параметр не особо принципиален — наверно потому, что совсем уж слабо типизированнах япов на рынке нет.
Когда меня спрашивают, сильная ли типизация в C# я говорю да. А когда спрашивают то же самое про тайпскрипт, я говорю нет. Но на деле, оба этих языка содержат имплиситные касты, и большой вопрос, в каком их больше. Я не знаю, какой тут алгоритм — как определить, сильная или строгая типизация у того или иного языка. Я даже не знаю, какая она должна быть, если честно. Думаю, меня вполне устроит, если функция, которая ждет строку, получит число и скастит его к строке, и думаю, меня совсем не устроит, если компилятор скастит к строке например boolean.
Есть ещё один параметр типизации, для которого я не знаю подходящего термина. Допустим у меня есть вот такой тип
type Person = {
id: string;
name: string;
}
Есть языки программирования, которые потащат в рантайм метаинформацию этого типа. Например C# позволяет на этапе исполнения посмотреть, какие поля и каких типов есть у такого-то класса. Это важная вещь — так я могу автоматизировать валидацию сторонних данных. А вот в тайпскрипте вся информация о типах есть только на этапе компиляции. Я раньше путал этот параметр с номинативной/структурной, но по факту я могу себе представить структурный яп, который тащит метаинформацию в рантайм.
Моего понимания не хватает, что бы сказать, нужно ли тащить метаданные типов в рантайм. Я знаю, что тайпскрипт этого не делает, и я решал проблемы, которые из-за этого возникают. Сишарп это делает, и это тоже вызывает проблемы — но я не знаю, были бы у меня такие проблемы, если бы сишарп при этом был структурно типизированным.
Это навело меня на мысль, что весь вопрос в комбинации этих параметров.
Вот тайпскрипт — статический, слабый, структурный и не тащит типы в рантайм. Такая конфигурация дает много плюсов — мы пишем гибкий код, у нас сохраняется проверка соответствия программы в компилтайме, наши доменные модули легко переносимы куда угодно. И у нас на порядок меньше бойлерплейта по приведению типов друг другу, чем в том же C#.
Более того, команда разработки тайпскрипта по сути работает только над статическим анализом — и это позволяет им сделать его по-настоящему мощным. Во всяких сишарпах, джавах, котлинах и т.д. невозможно сделать на этапе компиляции вещи, которые для тайпскрипта обыденность. Я имею в виду магию с conditional и mapped types. Я никогда не достигну такого уровня проверки корректности на этапе компиляции в C#.
Но есть гигантский минус. При кодировании на тайпскрипте система типов защищает код на тайпскрипте от кода на тайпскрипте — и никак не помогает с внешними данными. Получается, что если я описал тип User, и получил список таких юзеров из JSON, мне придётся руками перечислять все свойства, и делать все проверки. Это объективно — говно.
Теоретически, мы можем научить компилятор тайпскрипта генерить нам рантайм проверки для определенных типов, и не тащить метаданные всех типов в рантайм — это было бы даже круче. но команда тайпскрипта следует своей философии не влиять на рантайм, и никогда на это не пойдет.
В этом смысле, тайпскрипт ведет себя так же, как и все остальные языки — когда у их системы типов есть недостатки, они используют костыли, чтобы их спрятать. Чтобы спрятать недостатки сишарпа, придумали AutoMapper. Я не буду говорить про те инструменты, которые есть на рынке, чтобы скрыть недостатки Java — вы и сами все знаете, эти джависты использовали все лазейки, которые только можно было, чтобы убежать от бойлерплейта.
Вот так и в тайпскрипте. С помощью хитрых костылей мы можем заставить его вести себя как номинативный ЯП — есть брендированные типы, есть приватные тайпгарды у классов. А ещё мы можем написать ещё больше костылей, и сделать себе инструмент для протаскивания типов в рантайм. Есть крутая либа — runtypes.ts, которая позволяет описать свои типы как объекты, выводить из них обычные тайпскриптовые типы, и при этом сохранять номинативные и рантайм проверки.
Получается, что в том же тайпскрипте я, по идее, могу эмулировать любую типизацию, какую только сам захочу. Я могу эмулировать и использовать элементы самых разных типизаций в большинстве языков программирования.
Что ещё больше размывает границы всех этих типизационных терминов.
Вот F# — номинативно типизированный ЯП. Но. Но. В нем можно написать функцию, которая работает с чем угодно, у чего есть такое-то поле, такого-то типа. Более того, я могу весь свой код писать в таком стиле — это будет структурно типизированный код на номинативно типизированном F#. Как с этим быть?
Сейчас, чем больше я понимаю про типы, тем меньше понимаю, как описать все типизации, разложить их по полочкам и отличить друг от друга. Ни один из общих терминов уже ничего мне не скажет о языке. Потому что мы описываем типизацию по существующим япам, а они все хрен пойми какие.
Но я уверен, что я не единственный об этом подумал, и у кого-то в мире есть хорошие ответы.
Смотрите мой подкаст
Автор: Philipp Ranzhin