Перейти к содержимому
39 / 58

Как создать тип DeepPartial<T>?


Полный ответ

Проблема со стандартным Partial:

TypeScript
interface Config {
  db: {
    host: string;
    port: number;
    credentials: {
      user: string;
      password: string;
    };
  };
  cache: {
    ttl: number;
    maxSize: number;
  };
}

// Partial делает опциональным только первый уровень
type ShallowPartial = Partial<Config>;
// { db?: { host: string; port: number; credentials: {...} }; cache?: {...} }

// Но db.host, db.port и т.д. по-прежнему обязательны!

Рекурсивная реализация:

TypeScript
type DeepPartial<T> = T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

// Теперь все уровни опциональны
type PartialConfig = DeepPartial<Config>;

const override: PartialConfig = {
  db: {
    port: 5433,
    // host, credentials — не обязательны
  },
  // cache — не обязателен
};

Более точная версия, которая не разворачивает массивы и Date:

TypeScript
type DeepPartial<T> = T extends Date | RegExp | Map<unknown, unknown> | Set<unknown>
? T
: T extends Array<infer U>
? Array<DeepPartial<U>>
: T extends object
? { [K in keyof T]?: DeepPartial<T[K]> }
: T;

Практическое применение — слияние конфигурации:

TypeScript
function deepMerge<T extends Record<string, unknown>>(
  base: T,
  override: DeepPartial<T>
): T {
  const result = { ...base };

  for (const key in override) {
    const baseVal = base[key];
    const overrideVal = override[key];

    if (
      typeof baseVal === "object" &&
      baseVal !== null &&
      typeof overrideVal === "object" &&
      overrideVal !== null &&
      !Array.isArray(baseVal)
    ) {
      (result as Record<string, unknown>)[key] = deepMerge(
        baseVal as Record<string, unknown>,
        overrideVal as DeepPartial<Record<string, unknown>>
      );
    } else if (overrideVal !== undefined) {
      (result as Record<string, unknown>)[key] = overrideVal;
    }
  }

  return result;
}

const defaultConfig: Config = {
  db: { host: "localhost", port: 5432, credentials: { user: "admin", password: "secret" } },
  cache: { ttl: 3600, maxSize: 1000 },
};

const merged = deepMerge(defaultConfig, {
    db: { port: 5433, credentials: { password: "new-secret" } },
  }); // merged.db.host === "localhost" (из базового) // merged.db.port === 5433 (из override) // merged.db.credentials.password === "new-secret"

Реальные кейсы

Обновление состояния в Redux/Zustand:

TypeScript
interface AppState {
  user: { name: string; settings: { theme: string; lang: string } };
  notifications: { enabled: boolean; sound: boolean };
}

function updateState(state: AppState, patch: DeepPartial<AppState>): AppState {
  return deepMerge(state, patch);
}

// Обновляем только тему — всё остальное не трогаем
updateState(state, { user: { settings: { theme: "dark" } } });

Ошибка: DeepPartial разворачивает массивы неправильно:

TypeScript
// ❌ Наивная версия превращает string[] в { [K in number]?: string }
type Naive<T> = { [K in keyof T]?: Naive<T[K]> };
type Bad = Naive<{ tags: string[] }>; // tags?: { [x: number]?: string } — сломано

// ✅ Правильная версия проверяет Array отдельно
type Good = DeepPartial<{ tags: string[] }>; // tags?: string[]

Резюме

DeepPartial<T> — рекурсивный mapped type, делающий опциональными свойства на всех уровнях вложенности. Добавьте проверки на Array, Date, Map, Set, чтобы не «разворачивать» их в mapped types. Основной кейс — мёрж конфигураций и частичное обновление глубоко вложенного состояния.

Как создать тип DeepPartial<T>? | JScriptiser