Загружаем научный разбор
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Подготавливаем текст, источники и редакционные примечания без изменения разметки страницы.
Автор: Казачкин Даниил Михайлович · Обновлено
Связываем структурную совместимость и generics TypeScript с теорией полиморфизма: keyof, T[K], variance, brands, runtime-границы и ограничения soundness.
TypeScript проверяет совместимость объектов преимущественно по их структуре. Если значение содержит требуемые поля совместимых типов, ему не обязательно явно объявлять implements нужного интерфейса. Generics добавляют параметры типов и позволяют сохранить связь между входом и выходом вместо замены конкретных типов на any. Вместе эти механизмы хорошо описывают JavaScript-код, но не превращают проверку компилятора в runtime-валидацию или формальное доказательство всей программы.
Чтобы пользоваться системой точно, полезно разделять три вопроса: какие значения совместимы по форме, какую зависимость выражает параметр типа и какое утверждение останется истинным после стирания типов при запуске.
В номинальной системе два типа связывает объявленное имя, наследование или явная реализация интерфейса. В структурной системе сравниваются доступные члены. Официальная документация TypeScript формулирует базовое правило так: источник совместим с целевым объектным типом, если содержит как минимум требуемые целевые члены совместимых типов.
interface HasId {
id: string;
}
class ArticleRecord {
constructor(
readonly id: string,
readonly title: string,
) {}
}
function printId(value: HasId): void {
console.log(value.id);
}
printId(new ArticleRecord('article-1', 'Индексы PostgreSQL'));
printId({ id: 'lesson-4', draft: true });ArticleRecord не объявляет implements HasId, а второй объект содержит дополнительное поле. Оба значения пригодны функции, потому что функция использует обещанную форму { id: string }.
Это не то же самое, что динамический duck typing. Решение принимается статическим анализатором до запуска на основании типов. В JavaScript runtime интерфейс HasId не существует и не проверяет сетевой ответ.
Новичка удивляет разница между передачей переменной и свежего объектного литерала:
interface LessonLink {
slug: string;
}
function openLesson(link: LessonLink): void {
console.log(link.slug);
}
const linkWithTitle = { slug: 'typescript-generics', title: 'Generics' };
openLesson(linkWithTitle); // структура содержит обязательный slug
openLesson({ slug: 'typescript-generics', title: 'Generics' });
// Ошибка excess property check для свежего литерала.Excess property check — дополнительная проверка вероятной опечатки в контекстно типизированном свежем литерале. Она не отменяет структурную совместимость и не означает, что объект целевого типа физически запрещено снабжать другими полями. Не стоит обходить сигнал бездумным as LessonLink: возможно, поле действительно названо неверно или API-контракт выбран не тот.
Если функция должна принимать только известные поля и отвергать остальные в runtime, одного интерфейса недостаточно. Нужна явная схема проверки и политика неизвестных ключей.
Сравним три identity-функции:
function identityAny(value: any): any {
return value;
}
function identityUnknown(value: unknown): unknown {
return value;
}
function identity<T>(value: T): T {
return value;
}
const article = identity({ id: 'article-1', title: 'CAP-теорема' });
article.title.toUpperCase();any отключает значимую проверку и теряет связь. unknown безопаснее для неизвестного входа, но требует сузить тип перед использованием. <T>(value: T): T обещает, что результат имеет тот же выведенный тип, что и аргумент. Параметр T важен не сам по себе, а потому, что встречается с обеих сторон сигнатуры.
Плохой generic часто содержит параметр только один раз:
function parseUnsafe<T>(text: string): T {
return JSON.parse(text) as T;
}Вызывающий выбирает T, но функция не получает доказательства, что JSON ему соответствует. Такая сигнатура маскирует assertion под вывод типа. Честнее вернуть unknown, затем проверить схему, либо принять валидатор, который действительно связывает runtime-проверку с результатом.
Внутри функции неизвестный T нельзя использовать так, будто у него есть любые поля. Constraint фиксирует минимальную структуру:
function getProperty<T extends object, K extends keyof T>(
object: T,
key: K,
): T[K] {
return object[key];
}
const lesson = {
slug: 'typescript-generics',
order: 2,
published: true,
};
const order = getProperty(lesson, 'order'); // number
const published = getProperty(lesson, 'published'); // boolean
// getProperty(lesson, 'missing'); // ключ не входит в keyof typeof lessonЗдесь K extends keyof T связывает ключ с объектом, а T[K] сохраняет точный тип выбранного свойства. Возврат объединения всех возможных типов полей был бы слабее: после литерального ключа пользователь всё равно получил бы string | number | boolean.
Constraint не обязан быть интерфейсом предметной сущности. Он описывает именно операции, нужные реализации. Чем шире ограничение, тем меньше допустимых вызывающих значений. Поэтому правило «добавим все поля на всякий случай» ухудшает переиспользование.
TypeScript сравнивает итоговую структуру после подстановки параметров. Пустой generic-интерфейс не использует T, поэтому разные аргументы типа не создают различимых членов:
interface Marker<T> {}
let numberMarker: Marker<number>;
const stringMarker: Marker<string> = {};
numberMarker = stringMarker; // итоговые структуры пусты
interface Box<T> {
value: T;
}
let numberBox: Box<number> = { value: 1 };
const stringBox: Box<string> = { value: 'one' };
// numberBox = stringBox; // value имеет несовместимый типПараметр типа не является скрытой runtime-меткой. Если он не влияет на члены, системе нечего сравнивать. Для предметных идентификаторов одинаковой структуры иногда применяют brand через уникальный символ, добавляя различимый член на уровне типов. Это локальный паттерн, а не runtime-защита:
declare const userIdBrand: unique symbol;
type UserId = string & { readonly [userIdBrand]: 'UserId' };
function asUserId(raw: string): UserId {
if (!/^user-[0-9]+$/.test(raw)) {
throw new Error('invalid user id');
}
return raw as UserId;
}Assertion сосредоточен после проверки формата. Если раздать as UserId по всему коду, brand создаст видимость гарантии без единого места, где она устанавливается.
Карделли и Вегнер систематизировали формы полиморфизма, включая параметрический полиморфизм и полиморфизм включения. В параметрическом случае код формулируется единообразно для семейства типов; в подтипном — значение более специального типа допускается там, где ожидается общий.
Работа Уодлера о parametricity показывает, что в достаточно строгом формальном языке из полиморфной сигнатуры можно выводить свойства функции. Интуиция для identity сильна: реализация, которая ничего не знает о T, имеет мало способов создать значение T и обычно должна вернуть полученное.
Но механически переносить этот вывод на произвольный TypeScript нельзя. В языке есть any, assertions, исключения, мутация, бесконечные вычисления и намеренно несостоятельные в строгом формальном смысле правила совместимости ради JavaScript-практики. Эта теория объясняет ценность сохранённой связи типов, но не выдаёт сертификат конкретной функции.
Для контейнеров и callbacks важно, где параметр типа производится и потребляется. Производитель возвращает T, потребитель принимает T. Направление допустимой подстановки называют variance. TypeScript обычно выводит variance из структуры generic-типа и применяет правила совместимости функций с учётом настроек компилятора и синтаксической формы членов.
interface Animal {
name: string;
}
interface Dog extends Animal {
bark(): void;
}
type Producer<T> = () => T;
type Consumer<T> = (value: T) => void;
const makeDog: Producer<Dog> = () => ({ name: 'Шарик', bark() {} });
const makeAnimal: Producer<Animal> = makeDog;
const logAnimal: Consumer<Animal> = value => console.log(value.name);
const logDog: Consumer<Dog> = logAnimal;Производитель собаки пригоден там, где обещано животное: результат имеет все требуемые поля. Потребитель любого животного пригоден там, где ему передадут только собаку. Обратные присваивания могли бы разрешить чтение отсутствующего bark или передать обычное животное обработчику собак.
Практический вывод — включить строгие настройки проекта, но всё равно читать документацию о совместимости. TypeScript прямо документирует места осознанной несостоятельности. «Компилируется» означает соответствие правилам выбранной конфигурации, а не отсутствие всех runtime-ошибок.
HTTP-ответ, значение из localStorage, результат JSON.parse и сообщение очереди приходят из runtime-мира. Аннотация не меняет байты:
type ApiLesson = { slug: string; order: number };
const response = await fetch('/api/lesson');
const payload: unknown = await response.json();
function isApiLesson(value: unknown): value is ApiLesson {
if (typeof value !== 'object' || value === null) return false;
const record = value as Record<string, unknown>;
return typeof record.slug === 'string' && typeof record.order === 'number';
}
if (!isApiLesson(payload)) {
throw new Error('API response does not match ApiLesson');
}
console.log(payload.slug);Минимальный guard годится для примера, но production-контракт может требовать целые диапазоны, отсутствие лишних полей, рекурсивные структуры и понятный список ошибок. Там уместна централизованная схема или сгенерированный контракт. Главное — граница unknown → проверенный тип должна быть видна.
Классификация Карделли и Вегнера не описывает все современные особенности TypeScript и не доказывает soundness его реализации. Это теоретическая рамка для видов полиморфизма, а конкретные правила языка задаются официальной документацией и компилятором выбранной версии.
Результаты parametricity Уодлера нельзя без условий применять к функции TypeScript. <T>(x: T) => T выражает сильное намерение, но any, assertion, исключение или бесконечный цикл позволяют написать реализацию, не соответствующую наивной интерпретации.
Структурная совместимость не доказывает предметную эквивалентность. UserId и ArticleId могут быть строками одинаковой формы, хотя смешивать их нельзя. При необходимости различие кодируют brand, отдельным объектом или API-контрактом и устанавливают его в проверенной точке.
Generics не выполняют runtime-валидацию и не оптимизируют JavaScript автоматически. Их практическая сила — сохранить отношения между уже известными типами и сделать недопустимые комбинации заметными при разработке. Надёжная граница системы всё равно требует проверки внешних данных, строгой конфигурации и тестов поведения.
Самостоятельный редакционный разбор ЯдроКода по работам Cardelli и Wegner, Wadler и официальному TypeScript Handbook. Формулировки и примеры созданы редакцией.
Программные примеры и объяснительная структура созданы редакцией; фрагменты исходных публикаций и документации не воспроизводятся.