Каждый frontend-разработчик сталкивался с ошибкой вида TypeError: Cannot read property 'name' of undefined
. Это часть целого класса ошибок в JavaScript, возникающих из-за несоответствия фактического формата данных ожидаемому. Расскажу, как избавиться от подобных проблем и добиться стабильности, внедрив три ключевых шага: API-слой, Backend-for-Frontend (BFF) и проверку с помощью Zod.
А в чём проблема?
Мы знаем, что пользовательский ввод нужно проверять, особенно на бэкенде, прежде чем сохранять данные в базу. Но почему многие фронтенд‑разработчики безоговорочно доверяют данным с бэкенда? Если у вас десятки (микро)сервисов, которые могут не знать о конкретном «фронтенде» и не обязаны заботиться о его формате, то откуда уверенность, что приходящие данные всегда будут строго соответствовать ожиданиям?
Некорректные данные могут появиться в любом звене цепочки (front → service A → service B) и породить массу ошибок: от банальной невозможности прочитать несуществующее свойство до сложных непредсказуемых багов. В результате пользователи видят сбои на сайте, а разработчики тратят часы на поиск причин.
Примеры типичных ошибок:
TypeError: Cannot read property "name" of undefined
TypeError: Cannot read properties of null (reading "age")
TypeError: Cannot destructure property "id" of "undefined" as it is undefined
TypeError: arr.map is not a function
TypeError: num.toFixed is not a function
TypeError: users.forEach is not a function
TypeError: Invalid value for number
RangeError: Invalid time value
Наша история: ошибка, которая изменила всё
В одном проекте было два endpoint'а для данных о продукте: один возвращал объект Product
с полем productInfo
, а другой — только ProductInfo
. Изначально они совпадали по структуре, и мы использовали один адаптер. Всё шло гладко, пока кто‑то не изменил структуру productInfo
в одном из endpoint'ов. Ошибка проявлялась только при запросах к определённым продуктам, и неделю никто не мог понять, где корень проблемы. Этот случай заставил нас переосмыслить подход к управлению данными и важность чётких контрактов между сервисами.
Решение проблемы
Мы последовательно внедрили три ключевых шага, которые стали основой стабильного взаимодействия:
1. API-слой: стандартизация запросов и данных
Вся логика работы с запросами вынесена в отдельные модули на основе Axios:
удаление лишних вложенностей;
преобразование snake_case в camelCase;
унификация структуры данных для фронтенда;
централизованная обработка ошибок.
Это даёт предсказуемость: фронтенд получает данные в едином формате и не зависит от особенностей конкретного сервиса.
Пример API-слоя:
//client.js
import axios from 'axios';
const apiClient = axios.create({
baseURL: '/api',
headers: { 'Content-Type': 'application/json' },
});
httpClient.defaults.maxRedirects = 0;
httpClient.defaults.timeout = 10000;
httpClient.defaults.httpAgent = new httpAgent({ keepAlive, scheduling: 'fifo', keepAliveMsecs });
apiClient.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.code === 'ECONNABORTED') {
return Promise.reject(new HttpTimeoutError(error));
}
if (error.response) {
return Promise.reject(new ApiError(error));
}
if (error.isAxiosError) {
return Promise.reject(new HttpClientInternalError(error));
}
if (axios.isCancel(error)) {
return Promise.reject(new HttpAbortedError(error));
}
return Promise.reject(new HttpUnknownError(error));
}
);
export default apiClient;
import client from './client';
export const fetchProduct = (id: string) =>
client({
method: 'GET',
url: `/product`,
params: {
id
},
}).then((data) => ({
id: data.id,
name: data.title,
price: data.price_info.value,
}))
2. BFF: объединение запросов и подготовка данных для фронтенда
Мы внедрили слой BFF на Fastify. Он стал посредником, который помогает сохранить целостность и предсказуемость данных и облегчает поддержку приложения, ведь основная работа с данными сконцентрирована в одном месте.
объединяет данные из нескольких источников;
контролирует возможные изменения структуры данных до передачи на фронт;
сокращает количество запросов с клиента, агрегируя всё в одном месте.
Пример маршрута BFF:
import Fastify from 'fastify';
const app = Fastify();
app.get('/products', async (request, reply) => {
const {userId, ...productData} = await fetchProduct(request.id);
const userData = await fetchUserData(userId);
reply.send({ ...productData, user: userData });
});
//
const fetchProduct = async (id) =>
client({
method: 'GET',
url: `http://localhost/api/product`,
params: {
id
},
}).then((data) => ({
id: data.id,
name: data.title,
price: data.price_info.value,
userId: data.user_info.id,
}));
const fetchUser = async (id) =>
client({
method: 'GET',
url: `http://localhost/api/user`,
params: {
id
},
}).then((data) => ({
id: data.id,
name: data.name,
preferences: data.preferences.map(({value}) => value),
}));
app.listen(3000, () => console.log('BFF is running'));
3. Zod: runtime-защита
С помощью Zod мы добавили проверку данных в BFF. Описав схему данных, можно автоматически проверять и запросы, и ответы. Ошибки в форматах и типах теперь видны ещё до того, как данные уйдут на фронтенд.
Проверка на уровне BFF гарантирует, что данные, отправляемые на фронтенд, всегда корректны. Это не только повышает стабильность приложения, но и экономит время на устранение багов.
Пример маршрута с проверкой:
import { z } from 'zod';
import fastifyZod from 'fastify-zod';
//...
app.register(fastifyZod);
export const ProductSchema = z.object({
id: z.string(),
name: z.string(),
price: z.number(),
user: z.object({
id: z.string(),
name: z.string(),
preferences: z.array(z.string())
})
});
app.get('/product/:id', {
schema: {
response: { 200: ProductSchema },
},
}, async (request, reply) => {
const {userId, ...productData} = await fetchProduct(request.id);
const userData = await fetchUserData(userId);
reply.send({ ...productData, user: userData });
});
Плюс не забывайте про тесты, чтобы исключить риск случайных изменений без обратной совместимости.
Тесты на Fastify, кстати, пишутся довольно просто
import {options} from 'app';
export function build() {
options.logger = false;
const app = Fastify(options);
beforeAll(async () => {
void app.register(fp(App));
await app.ready();
});
afterAll(() => app.close());
return app;
}
describe('/api/getProduct', () => {
it('id is required', async () => {
expect.assertions(1);
const res = await app.inject({
url: '/api/product'
});
expect(JSON.parse(res.payload)).toEqual({ code: 'fst-02' });
});
it('response is valid', async () => {
expect.assertions(2);
jest.spyOn(resource, 'fetchProduct').mockResolvedValue({...productMock, userId: 999});
jest.spyOn(resource, 'fetchUser').mockResolvedValue(userMock);
const res = await app.inject({
url: '/api/product?id=123'
});
expect(fetchProduct).toHaveBeenCalledWith('123');
expect(fetchProduct).toHaveBeenCalledWith(999);
expect(res.payload).toEqual(JSON.stringify({...productMock, user: userMock}));
});
});
Важно, что Zod позволяет использовать типы, выведенные из схемы. Таким образом обеспечивается согласованность между реальными данными с BFF и типами в коде приложения.
// обновленный fetchProduct.ts (сразу включает и запрос пользователя по product)
import { ProductSchema } from 'bff/routes/getProduct';
import client from './client';
type Product = z.infer<typeof ProductSchema>;
export const fetchProduct = (id: string) =>
client({
method: 'GET',
url: `/product`,
params: {
id
},
}).then((data: ProductSchema) => data)
Результаты
Меньше багов. Большинство ошибок теперь отлавливаем на этапе обработки данных.
Быстрая разработка. Командам не нужно постоянно уточнять формат данных, так как он зафиксирован в схемах.
Высокая стабильность. Фронтенд не ломается из-за неожиданного изменения данных на бэкенде.
Теперь взаимодействие с API предсказуемо и эффективно, а опыт, полученный в процессе внедрения этих практик, помогает команде расти и развивать приложение дальше.
Что делать дальше?
Прежде чем внедрять весь комплекс решений, можно начать с небольших шагов:
Вынести все запросы в отдельный API-слой, чтобы единообразно обрабатывать ответы и ошибки.
Добавить базовую проверку (хотя бы самых критичных данных) с помощью Zod или аналогичных библиотек.
Оценить необходимость BFF: если у вас несколько сервисов и сложные трансформации, то BFF может существенно упростить жизнь.
Если у вас уже есть похожий опыт, поделитесь своими находками в комментариях. А если остались вопросы или идеи — смело задавайте, с радостью обсудим!
savostin
Спорное решение - добавить бутылочное горлышко в виде одного эндпоинта и использовать не самый быстрый (но чертовски удобный) zod