RawObjectDataProcessor
— Обзор проблематики
Работа с неизвестными заранее внешними данными — одна из базовых задач программирования. Такими внешними данными могут быть:
- Данные, полученные с клиентской части в клиент-серверном взаимодействии
- Наоборот, данные с серверной части в клиент-серверном взаимодействии
- Данные из базы данных
- Данные из файла (JSON, YAML и подобных)
Поскольку такие данные находятся вне досягаемости TypeScript, то изначально имеют тип unknown
или, что ещё хуже, any
.
Как обычно действуют в данной ситуации? К сожалению, зачастую не так, как надлежит при написании качественного кода. Если при получении данных с клиентской части при клиент-серверном взаимодействии валидация данных считается мерой безопасности, а потому осуществляется, то во многих других случаях, например при получении данных с сервера этим данным просто доверяют, а потому помечают их желаемым типом:
type User = {
ID: string;
familyName: string;
givenName: string;
};
fetch("http://example.com/users/1").
then((response: Response): void => response.json()).
then((data: unknown) => {
// Это пример ПЛОХОГО кода. НЕ ПОДРАЖАЙТЕ ЕМУ!!!
const user: User = data as User;
const fullName: string = `${user.givenName} ${user.familyName}`;
console.log(fullName);
});
Как показывает практика, в подавляющем большинстве проектов среднего и крупного масштабов случается расхождение между ожидаемыми и реальными данными, особенно если клиентская и серверная часть разрабатываются на разных языках программирования и отдельными командами. Число таких расхождений может быть очень большим — от нескольких десятков до нескольких сотен и даже тысяч. Причиной тому может быть как элементарный человеческий фактор, так и отсутствие своевременного уведомления причастных инженеров об изменении в данных.
А если Вы создаёте какую-либо утилиту с декларативной конфигурацией через файл (обычно JSON, YAML и т. д.) наподобие docker compose
, то там неверно указанная конфигурация является обычным сценарием. Поэтому перед тем, как работать в внешними данными, их необходимо валидировать, то есть проверить, соответствуют ли реальные данные установленным ограничениям.
Подходы без использования библиотек
Защитники типов — нативная функциональность TypeScript. Защитники типов представляют собой функции, возвращающие булевское значение, при этом возвращаемое значение аннотируется не как boolean
, а наподобие x is T, где x — параметр, тип которого предстоит проверить, T — желаемый тип:
type User = {
ID: string;
familyName: string;
givenName: string;
};
function isUser(rawData: unknown): rawData is User {
return typeof rawData === "object" &&
rawData !== null &&
"ID" in rawData && rawData.ID === "string" &&
"familyName" in rawData && rawData.familyName === "string" &&
"givenName" in rawData && rawData.givenName === "string";
}
Помимо документации TypeScript, защитники типов были хорошо разобраны в статье фронтенд-иженера Marius Schulz. Для нас важно что:
true
реальный тип параметра будет соответствовать желаемому — это лишь просьба TypeScript-у поверить, что это так. В реальности же всё то, что осуществляются в теле функции-защитника, может вообще не иметь никакого отношения к желаемому типу.Вот пример ошибочного кода, в котором защитник isUser
типа User
проверят у параметра те поля, которые вообще не имеют отношения к желаемому типу User:
type User = {
ID: string;
familyName: string;
givenName: string;
};
// НАМЕРЕННО ОШИБОЧНЫЙ ПРИМЕР!!!
function isUser(rawData: unknown): rawData is User {
return typeof rawData === "object" &&
rawData !== null &&
"title" in rawData && rawData.title === "string" &&
"price" in rawData && rawData.price === "string";
}
const potentialUser: unknown = { title: "Shampoo", price: 1000 };
if (isUser(potentialUser)) {
console.log(potentialUser.familyName);
}
Несмотря на то, что содержимое переменной potentialUser
и близко не имеет отношение к типу User
, isUser
вернёт true
и TypeScript даже не заподозрит, что что-то не так. Более того, TypeScript не выразит не малейшего недовольства, если в теле функции-защитника вообще ничего не будет проверяться:
type User = {
ID: string;
familyName: string;
givenName: string;
};
// НАМЕРЕННО ОШИБОЧНЫЙ ПРИМЕР!!!
function isUser(rawData: unknown): rawData is User {
console.log(rawData);
return Math.random() >= 0.5;
}
Почему же всё так плохо? Если кратко, то из-за фундаментальных ограничений TypeScript. Валидация данных (в том числе с помощью защитников типов) осуществляется во время выполнения JavaScript-а, когда исходного TypeScript-кода уже нет. В выходном JavaScript-коде защитник типов — уже обычная JavaScript-функция, ничем не отличающаяся по своей природе от других функций, возвращающих булевское значение.
Такие особенности языка TypeScript, как алиас типов (ключевое слово type
) или интерфейсы существуют только в рамках исходного TypeScript-кода, но в выходном JavaScript-коде их нет, следовательно какого-либо способа сослаться на них тоже нет. Теоретически, можно было бы реализовать генерацию вспомогательных функций и/или объектов на основе алиасов типов и интерфейсов в исходном TypeScript-коде, чтобы потом использовать их для валидации без ручного написания кода, однако маловероятно, что команда разработки TypeScript в ближайшем будущем реализует что-то подобное.
Помимо выше описанной, у защитников типов есть ещё несколько существенных проблем:
- Защитники типов по своей концепции только отвечают на вопрос, валиден ли параметр или нет, при этом где именно имеются нарушения, сообщено не будет.
- Защитник типов вернёт
false
при первом же ложном условии, хотя могут быть и другие ложные условия.
Вообще-то говоря, эти проблемы являются таковым лишь де-факто, потому что как уже было упомянуто выше, при реализации защитников типов TypeScript требует только две вещи: обязательного возврата булевского значения и особой аннотации возвращаемого типа, а в теле функции можно реализовать всё, что угодно, включая логирование, полную проверку всех свойств и так далее. Проблема лишь в том, что в реальности так почти никто не делает, и на то есть веская причина. Если Вы в реальном (не учебном) проекте среднего или большого масштаба начать реализовывать защитники типов с выше перечисленной функциональностью, то очень быстро возникнет ещё одна неприятная проблема: слишком много рутинного кода, причём местами он почти одинаковый. Особенно это касается качественного логирования: будет много однотипных сообщений, и их придётся либо каждый раз писать заново, либо организовывать вынос сообщений в отельные объекты и/или файлы, пока не встанет вопрос о вынесении всего этого кода в библиотеку. А в реальном проекте объекты будут отнюдь не такие простые, как User из примера выше, а могу иметь по 20-30 свойств, и это не предел, причём часто будут и вложенные объекты, в частности массивы, и зачастую с элементами типа «объект», которые тоже надо валидировать. Обилие рутинного кода повышает вероятность ошибок из-за усталости, потому нативное решение задачи хотя имеется, но не является практичным.
Однако, это не делает защитники типов бесполезными — просто они плохо справляются с валидацией объектов с большим количеством свойств, но для других типов значений (строки, числа и так далее) они не просто подходят, но обычно используются в большом количестве. YDEE также предлагает набор защитников типов, многие из которых используются и внутри библиотеки:
Подход RawObjectDataProcessor
Итак, ввиду того что при транспайлинге TypeScript в JavaScript интерфейсы и алиасы типов ( ключевое слово type) прекращают своё существование, на них во время выполнения JavaScript-кода сослаться никак нельзя, потому исчерпывающе доказать, что конкретный объект имеет конкретный тип невозможно — ведь даже если есть соответствующий защитник типа, находящаяся внутри него логика может вообще не иметь отношения к проверке на нужные свойства. Однако перед тем, как использовать ключевое слово as
, следует это использование чем-либо подкрепить, а именно валидацией, при этом в целях поддерживаемости валидатор должен детально логировать все несоответствия установленным, а не только первое из них. При таком раскладе, даже если указанные правил валидации и не будут соответствовать реальному типу (например, из-за допущенной по усталости ошибки), как показывает практика, это очень быстро обнаруживается.
Таким образом, RawObjectDataProcessor берёт на себя грех использования as, требуя взамен указать спецификацию валидных данных почти что в декларативном виде. Рассмотрим ещё раз демо в свете описанной выше теории.
function onDataRetrieved(externalData: unknown): void {
type SampleType = {
foo: number;
bar: string;
baz: boolean;
hoge?: number;
fuga: string | null;
quux: {
alpha: number;
bravo: "PLATINUM" | "GOLD" | "SILVER";
};
};
const externalDataProcessingResult: RawObjectDataProcessor.ProcessingResult<SampleType> = RawObjectDataProcessor.
process(
externalData,
{
nameForLogging: "Example",
subtype: RawObjectDataProcessor.ObjectSubtypes.fixedSchema,
properties: {
foo: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero
},
bar: {
type: String,
isUndefinedForbidden: true,
isNullForbidden: true,
minimalCharactersCount: 5
},
baz: {
type: Boolean,
isUndefinedForbidden: true,
isNullForbidden: true
},
hoge: {
type: Number,
isUndefinedForbidden: false,
isNullForbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero
},
fuga: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: false,
numbersSet: RawObjectDataProcessor.NumbersSets.positiveIntegerOrZero
},
quux: {
type: Object,
isUndefinedForbidden: true,
isNullForbidden: true,
properties: {
alpha: {
type: Number,
isUndefinedForbidden: true,
isNullForbidden: true,
numbersSet: RawObjectDataProcessor.NumbersSets.anyInteger,
minimalValue: 3
},
bravo: {
type: String,
isUndefinedForbidden: true,
isNullForbidden: true,
minimalCharactersCount: 5,
allowedAlternatives: [ "PLATINUM", "GOLD", "SILVER" ]
}
}
}
}
}
);
}
Сначала RawObjectDataProcessor проверит, соответствует ли реальное значение externalData
спецификации, переданной через второй параметр. Как и обычный защитник типов, RawObjectDataProcessor программно проверяет свойства, однако если что-то не так, то не прекращает проверку немедленно (за исключением случаев, когда сам externalData
не является объектом), а сохраняет сообщение о несоответствии в externalDataProcessingResult.validationErrorsMessages
:
if (externalDataProcessingResult.isRawDataInvalid) {
throw new InvalidExternalDataError({
mentionToExpectedData: "N External Data",
messageSpecificPart: RawObjectDataProcessor.
formatValidationErrorsList(externalDataProcessingResult.validationErrorsMessages)
});
}