Любой внешний payload на входе, например фетч или импорт JSON, по молчанию имеет тип any. Что мы обычно делаем? Правильно, кастим тип:
type PageResponse = {
status: "success" | "error";
data?: { contentHtml?: string; nextPageUrl?: string };
};
const json = (await response.json()) as PageResponse;
Но кастить тип через as равно врать компилятору.
TypeScript мы заткнули, только рантайм про контракт ничего не знает. Если формат поменялся, создается потенциальный баг в приложении.
Чтобы не врать, контракт нужно описать один раз и прогнать через него данные на границе. Для этого можно использовать Zod: схема валидирует в рантайме и сужает тип. Это называют "type narrowing через runtime parser": unknown/any -> parse -> конкретный тип.
const pageDataSchema = z.object({
contentHtml: z.string().optional(),
nextPageUrl: z.string().optional(),
});
const parsePageResponse = (raw: unknown) =>
z
.object({
status: z.enum(["success", "error"]),
message: z.string().optional(),
data: pageDataSchema.optional(),
})
.parse(raw);
type PageResponse = ReturnType<typeof parsePageResponse>;
const json = parsePageResponse(await response.json());
Тип ответа берём из ReturnType парсера (или из схемы через z.infer), а не пишем отдельно руками (не хардкодим).
Тот же приём работает не только для JSON: куки, query, localStorage, postMessage, FormData - всё, что пришло "извне".
Научите ваших агентов.