Когда разработчик начинает писать на Next.js с TypeScript, первая реакция часто довольно холодная. Вместо того чтобы двигаться быстрее, он начинает чаще видеть ошибки. Где-то не совпал shape объекта, где-то строка не подходит в более узкий тип, где-то TypeScript напоминает, что значение может быть undefined. На этом месте легко сделать неправильный вывод. Кажется, что TS просто добавляет трение и требует больше служебного кода.

Обычно проблема не в TypeScript, а в способе мышления. Если использовать его как набор аннотаций поверх уже написанного кода, пользы действительно немного. Но если смотреть на типы как на систему контрактов между слоями приложения, картина меняется. Особенно в Next.js App Router, где у нас постоянно есть границы server и client, внешний ввод из URL, формы, мутации и разные состояния интерфейса.

В этот момент TypeScript перестаёт быть типизацией ради типизации. Он начинает отвечать на более важный вопрос: какие состояния в проекте вообще допустимы, а какие не должны пройти дальше границы. По такой модели я выстроил один из своих проектов Workbench. Не начинать с мысли давайте везде поставим типы, а начинать с мысли где у нас проходит граница, что в неё входит и что из неё может выйти. После этого многие решения в коде становятся почти очевидными.

Где TypeScript реально приносит пользу в Next.js

В обычном React SPA типы часто сводят к пропсам и интерфейсам компонентов. В Next.js этого мало. Здесь есть как минимум четыре зоны, где контракт особенно важен.

Первая зона - сервер передаёт данные в клиентский компонент.
Вторая зона - страница читает внешний ввод из params и searchParams.
Третья зона - приложение хранит сущности, которые должны иметь устойчивую форму.
Четвёртая зона - форма и мутация возвращают в UI не произвольный объект, а предсказуемый результат.

Если эти места описаны типами с самого начала, проект становится заметно устойчивее. Если нет, TypeScript позже всё равно начнёт сигналить, но уже в режиме аварийного ремонта.

Контракт server -> client

Один из самых недооценённых узлов в App Router - это передача данных из Server Component в Client Component. На уровне ощущения кажется, что это просто props. Но по факту это контракт между двумя разными мирами.

Сервер собрал данные, клиент их получил. Если здесь нет чёткой формы, UI начинает опираться на догадки. Сначала всё терпимо, потом в payload незаметно попадает лишнее поле, потом кто-то меняет структуру сущности, потом клиентский код продолжает ожидать старую форму.

Оформим это место отдельным типом payload.

// src/demo/contracts.ts
export type HelloPayload = {
  appName: string;
  renderedAt: string;
  mode: "server-to-client";
  initialNotes: DemoNote[];
};

                  

А серверная страница собирает объект уже по этому контракту.

// src/app/(demo)/demo-contract/page.tsx
import type { HelloPayload } from "@/demo/contracts";
import { getNotes } from "@/demo/notesStore";
import HelloClient from "./HelloClient";

export default function DemoContractPage() {
  const payload: HelloPayload = {
    appName: "Workbench Notes",
    renderedAt: new Date().toISOString(),
    mode: "server-to-client",
    initialNotes: getNotes(),
  };

  return <HelloClient payload={payload} />;
}

                  

На первый взгляд это может показаться избыточным. Но здесь и появляется инженерная выгода. Клиент не предполагает, какие данные придут. Сервер не передаёт абы что. Оба слоя согласованы через тип, а не через устную договорённость.

В Next.js это особенно полезно, потому что граница server и client в App Router не декоративная. Она влияет и на рендер, и на зависимости, и на сериализуемость данных, и на то, как вообще мыслить потоком приложения.

Типы полезны не внутри компонента, а на границе входа

Ещё одна частая ошибка выглядит так. Разработчик получает внешний ввод из URL и старается как можно быстрее протащить его глубже в код. Например, параметр note из searchParams или projectId из params. TypeScript начинает сопротивляться. Вместо того чтобы признать, что внешний ввод ещё не нормализован, разработчик делает type assertion и идёт дальше.

Это почти всегда плохой путь. Внешний ввод по определению грязный. Он может отсутствовать, иметь не тот формат, прийти как string[], содержать мусор. TypeScript здесь мешает не потому, что он строгий, а потому, что он показывает реальную проблему. До проверки это ещё не валидное доменное значение.

Внешний ввод нужно приводить на границе, а не размазывать проверки по компонентам.

// src/demo/noteId.ts
export function parseNoteId(value: unknown): DemoNoteId | null {
  if (typeof value !== "string") return null;

  const v = value.trim();

  if (!/^n\d+$/.test(v)) return null;

  return v;
}

                  

После этого код ниже по дереву работает уже не с абстрактной string, а с прошедшим проверку значением. И это ключевой сдвиг. TypeScript нужен не для украшения внутренней логики, а для того, чтобы не пропускать сырой внешний ввод в доменный слой.

Тот же принцип полезен и для searchParams. Значение параметра может быть string, string[] или undefined. Если не нормализовать это сразу, дальше почти любой вызов будет сопровождаться лишними проверками или, что хуже, небезопасными допущениями.

// src/lib/searchParams.ts
export type StringParamValue = string | string[] | undefined | null;

export function getStringParam(value: StringParamValue): string | undefined {
  if (typeof value === "string") return value;
  if (Array.isArray(value)) return value[0];
  return undefined;
}

                  

Это мелкая утилита, но у неё важная роль. Она отрезает хаос внешнего ввода от остального приложения. После неё проект работает уже с простой формой данных.

Хороший тип не только описывает данные, но и убирает невозможные состояния

Очень часто TypeScript воспринимают как проверку полей объекта. Но его реальная сила в другом. Он позволяет сделать невозможные состояния буквально невыразимыми в коде. В сущностях это может выглядеть довольно прозаично.

// src/@types/demo-notes.d.ts
type DemoNoteStatus = "draft" | "published" | "archived";
type DemoNotePriority = 1 | 2 | 3;

type DemoNote = {
  id: DemoNoteId;
  title: string;
  status: DemoNoteStatus;
  priority: DemoNotePriority;
  tags: string[];
  description: string;
  createdAt: IsoDateString;
  updatedAt: IsoDateString;
};

                  

Смысл здесь не в красоте определения. Смысл в том, что внутри проекта заметка всегда полная. У неё не бывает статуса, написанного с ошибкой. У неё не бывает priority со значением 7. У неё не бывает внезапно отсутствующего tags или description, если на уровне модели мы решили, что внутри системы сущность должна быть уже собрана.

Именно поэтому отдельно существует входной тип, где часть полей ещё может отсутствовать.

// src/@types/demo-notes.d.ts
type DemoNoteCreateInput = {
  title: string;
  status?: DemoNoteStatus;
  priority?: DemoNotePriority;
  tags?: string[];
  description?: string;
};

                  

А дальше на границе сборки мы превращаем неполный ввод в полную сущность через дефолты.

// src/demo/notesStore.ts
export function createNote(input: DemoNoteCreateInput): DemoNote {
  const id: DemoNoteId = `n${Date.now()}`;
  const t = new Date().toISOString();

  const note: DemoNote = {
    id,
    title: input.title.trim(),
    status: input.status ?? "draft",
    priority: input.priority ?? 2,
    tags: input.tags ?? ["new"],
    description: input.description ?? "",
    createdAt: t,
    updatedAt: t,
  };

  notes = [note, ...notes];
  return note;
}

                  

Это пример того, как TypeScript помогает не усложнять UI, а наоборот разгружать его. Если сущность внутри системы уже нормализована, компонентам не приходится бесконечно проверять, есть ли у заметки description, пришёл ли tags, указан ли status. Они работают с устойчивой моделью.

Ошибка TypeScript часто указывает не на строку, а на сломанный контракт

Один из самых полезных разворотов в работе с TS выглядит так: ошибку нужно читать не как локальную помеху, а как сигнал о неправильном контракте.

Возьмём классический случай Type is not assignable. Часто его пытаются чинить приведением типа. Но это не исправление, а подавление сигнала. Пример с id.

// src/demo/tsErrors/typeIsNotAssignable.ts
import { getNoteById } from "@/demo/notesStore";
import { parseNoteId } from "@/demo/noteId";

export function demo_id_barrier(raw: string) {
  const noteId = parseNoteId(raw);
  if (!noteId) return null;

  return getNoteById(noteId);
}

                  

Здесь внешний raw не проталкивается напрямую в доменную функцию. Между ними есть барьер. Сначала проверка, потом работа.

А вот так выглядит плохой фикс.

// src/demo/tsErrors/typeIsNotAssignable.ts
export function demo_bad_fix(raw: string) {
  const unsafeId = raw as any;
  return getNoteById(unsafeId);
}

                  

TypeScript замолчал, но приложение не стало надёжнее. Наоборот, контракт между слоями исчез. Это и есть главный практический критерий. Хороший фикс усиливает контракт. Плохой фикс просто выключает сигнализацию.

Possibly undefined обычно лечится не оператором, а правильной границей

Ещё одна ошибка, которую почти все встречают рано или поздно, это Possibly undefined. В реальных проектах она всплывает вокруг URL, поиска по store и промежуточных результатов парсеров.

Самая частая реакция - форсировать доступ через оператор non-null assertion. Но в большинстве случаев правильнее признать, что значение действительно может отсутствовать, и описать это явно.

// src/demo/tsErrors/possiblyUndefined.ts
export function demo_parseNoteId(raw: string) {
  const noteId = parseNoteId(raw);

  if (!noteId) return null;

  return getNoteById(noteId);
}

                  

Это может выглядеть как дополнительный код, но на самом деле такой guard убирает двусмысленность из всей следующей ветки. После раннего return TypeScript уже знает, с каким типом мы работаем дальше.

В App Router это полезно, потому что params, searchParams, form data, состояние загрузки и результаты поиска постоянно создают ветви, где данные ещё не гарантированы. Если описывать их как есть, код становится предсказуемее. Если пытаться притвориться, что данные точно есть, проблемы просто переезжают дальше по цепочке.

Union-тип чаще полезнее, чем набор разрозненных флагов

Отдельный класс пользы TypeScript проявляется не на данных, а на состояниях операции. Например, когда результат действия может быть успешным или ошибочным. В небольших проектах это часто описывают несколькими флагами. isLoading, hasError, data, errorMessage. Сначала всё терпимо, потом появляются нестыковки вроде data есть, error тоже есть, а loading уже false.

Рассмотрим более жёсткий и полезный шаблон.

// src/demo/actionResult.ts
export type ActionResult<T> =
  | { ok: true; data: T }
  | { ok: false; error: string };

export function ok<T>(data: T): ActionResult<T> {
  return { ok: true, data };
}

export function fail(error: string): ActionResult<never> {
  return { ok: false, error };
}

                  

На уровне UI это даёт очень важный эффект. После проверки if (state.ok) TypeScript автоматически сужает тип. В успешной ветке доступно data. В ошибочной ветке доступно error. Не нужно надеяться, что флаги согласованы между собой. Сама модель состояния уже не позволяет выразить нелогичную комбинацию.

// src/demo/tsErrors/narrowingByOk.ts
export function demo_narrowing() {
  const state = createResult(Math.random() > 0.5);

  if (!state.ok) {
    return state.error;
  }

  return state.data.title;
}

                  

Именно такие union-типы в какой-то момент начинают ощущаться как настоящий каркас проекта. Они не просто описывают ответ, а задают дисциплину обработки.

Почему это особенно хорошо ложится на Next.js

В Next.js App Router архитектура сама подталкивает к мышлению контрактами. У нас есть серверные страницы, клиентские компоненты, внешние параметры маршрута, формы, server actions, сериализуемые props, разные формы загрузки и обновления данных. Если в такой системе типы остаются декоративными, проект быстро теряет чёткость.

А если смотреть на тип как на границу между слоями, то TypeScript начинает работать вместе с архитектурой фреймворка. Сервер передаёт клиенту payload по заранее описанной форме. Внешний ввод проходит нормализацию до того, как попадёт в бизнес-логику. Сущности внутри приложения существуют уже в полной нормальной форме. Результаты действий возвращаются в виде union-состояний, где UI не может случайно прочитать несуществующее поле.

На практике это даёт очень приземлённый эффект. Код легче менять. Ошибки появляются раньше. Фиксы чаще происходят в правильном месте. Компоненты содержат меньше защитной импровизации. А новые части проекта собираются быстрее, потому что многие решения уже вынесены в контракты.

Что получается после всего этого

Самый полезный сдвиг здесь даже не в синтаксисе TypeScript. Он в том, что разработчик перестаёт мыслить типами как декларациями и начинает мыслить ими как инженерными барьерами.

Не просто у объекта есть поля, а сервер обязан передать клиенту именно такую форму.
Не просто функция принимает string, а внешний ввод обязан пройти проверку до входа в доменный слой.
Не просто операция возвращает объект, а UI получает только одно из допустимых состояний.

Когда смотришь на TS так, он перестаёт быть служебной надстройкой. Он становится способом удерживать архитектуру в рабочем состоянии по мере роста проекта. Становится полезнее всего не в месте, где он красиво подсказывает тип пропса, а там, где не даёт сломать границу между слоями приложения.

В итоге

Если в проекте на Next.js TypeScript пока ощущается как лишняя строгость, почти всегда причина в одном. Он используется слишком поздно и слишком локально.

Рабочий разворот выглядит иначе. Сначала ищем границы. Потом описываем контракт. Потом приводим внешний ввод на входе. Потом собираем устойчивые сущности внутри системы. Потом используем union-тип там, где операция не может находиться сразу в нескольких состояниях. После этого TS начинает работать не как формальная типизация, а как защита архитектуры от случайных поломок.

Для примеров в статье использован живой проект Workbench
Полная последовательная сборка этих паттернов разобрана в Stepik-курсе Next.js II: TypeScript 2026.