typescript-without-any.md — vim

Три приёма, после которых any почти перестал появляться в коде

as const, satisfies и размеченные объединения. Без теории типов, на примерах статусов, конфигов и состояний загрузки.


any - это не типизация, это её выключатель. Каждый any в коде означает «здесь мы проверим сами». Обычно не проверяем.

Ниже три штуки, которые закрыли большинство мест, где раньше рука тянулась к any или as.

as const

Без него TypeScript расширяет всё до самого широкого типа:

const STATUS = { NEW: 'new', DONE: 'done' };
// { NEW: string; DONE: string } - литералы потеряны

function setStatus(s: 'new' | 'done') {}
setStatus(STATUS.NEW); // ошибка: string не подходит

Дописываем два слова:

const STATUS = { NEW: 'new', DONE: 'done' } as const;

type Status = typeof STATUS[keyof typeof STATUS]; // 'new' | 'done'
setStatus(STATUS.NEW); // ок

Заодно это убирает нужду в enum. Обычный объект даёт тот же союз типов, но остаётся нормальным JS-значением, которое можно перебрать в v-for.

satisfies

Штука, которую я жду с четвёртой версии и до сих пор радуюсь. Классическая дилемма: напишешь аннотацию - потеряешь точность.

type Route = { path: string; roles: string[] };

const routes: Record<string, Route> = {
  users: { path: '/users', roles: ['admin'] },
};

routes.orders; // TS молчит, хотя такого ключа нет

satisfies проверяет соответствие типу, но оставляет выведенный конкретный тип:

const routes = {
  users:  { path: '/users',  roles: ['admin'] },
  orders: { path: '/orders', roles: ['admin', 'manager'] },
} satisfies Record<string, Route>;

routes.orders.path; // известен
routes.customers;   // ошибка компиляции

Я перевёл на это все словари: карты роутов, темы, переводы, маппинги статус → цвет. Опечатку в ключе теперь ловит компилятор, а не тестировщик за день до релиза.

Размеченные объединения

Тип, который есть в любом проекте:

interface State {
  loading: boolean;
  data?: User[];
  error?: string;
}

Он разрешает бессмыслицу: loading: true одновременно с data и error. Дальше в шаблоне появляется v-if="!loading && !error && data", и однажды кто-то забудет одну проверку.

Размечаем по полю:

type State =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'success'; data: User[] }
  | { status: 'error'; error: string };

function render(state: State) {
  switch (state.status) {
    case 'success': return state.data.length; // data точно есть
    case 'error':   return state.error;
    case 'loading': return '...';
    case 'idle':    return '';
  }
}

Невалидное состояние теперь невозможно выразить. А если добавить вот такую функцию в default, то при появлении нового статуса компилятор напомнит про забытую ветку:

function assertNever(x: never): never {
  throw new Error(`Необработанный вариант: ${JSON.stringify(x)}`);
}

А если тип реально неизвестен

Тогда unknown, а не any. Разница простая: с any можно делать что угодно, с unknown - ничего, пока не проверишь.

const data: unknown = JSON.parse(raw);

data.name; // не даст
if (typeof data === 'object' && data && 'name' in data) {
  // а тут уже можно
}

Для всего, что приходит извне - ответ API, localStorage, query-параметры - лучше сразу гнать через схему (Zod, Valibot, любая). Тогда тип не обещан, а проверен в рантайме.

Аргумент, который в спорах работает лучше всего: типы - это тесты, которые пишешь один раз, а выполняются они на каждом сохранении файла. any выключает их ровно там, где данные приходят снаружи. То есть там, где ломается чаще всего.

У меня на ревью правило: any можно, но с комментарием, почему тип невыразим. За полгода такие комментарии появились раза три, всё остальное закрылось unknown и союзами.

Пруфы