Переопределение типа свойства интерфейса, объявленного в d.ts файле
Проблема: почему нельзя просто переопределить свойство
TypeScript строго следит за типами при наследовании интерфейсов. Когда вы пишете interface MyUser extends User, вы говорите: "Мой интерфейс должен содержать все свойства из User, но с некоторыми изменениями". Однако TypeScript не позволяет сужать тип унаследованного свойства — это нарушило бы контракт базового интерфейса. Декларативное слияние (когда два интерфейса с одним именем объединяются) тоже не работает, если типы свойств различаются.
Решение: утилитарный тип Omit + собственный интерфейс
Самый популярный и правильный способ — использовать встроенный утилитарный тип Omit, который "вырезает" указанные свойства из интерфейса, а затем добавить их с новым типом:
import { User } from 'some-library';
interface MyUser extends Omit<User, 'id'> {
id: string; // Теперь всё работает!
}
// Пример использования:
const user: MyUser = {
id: 'abc123', // строка
name: 'Иван',
email: 'ivan@test.com',
age: 25 // осталось числом
};
Как это работает:
Omit<User, 'id'>создаёт тип, который содержит все свойства из User, кромеid.- Затем мы наследуем этот "урезанный" тип и добавляем своё свойство
id: string. - Конфликта типов не возникает, так как мы сначала удалили оригинальное свойство, а потом добавили новое.
Альтернатива: тип-дженерик с условным переопределением
Если вам нужно переопределять свойства динамически, можно создать вспомогательный тип:
type Override<T, K extends keyof T, V> = Omit<T, K> & { [P in K]: V };
// Использование:
type MyUser = Override<User, 'id', string>;
// MyUser.id теперь string, остальные поля — как в User
Если нужно переопределить несколько свойств
Просто перечислите их в Omit и добавьте нужные:
interface MyUser extends Omit<User, 'id' | 'email'> {
id: string;
email: string | null; // теперь может быть null
}
Важный нюанс: изменение сигнатуры метода
Если в интерфейсе есть методы, и вы хотите изменить их типы параметров или возвращаемое значение — подход тот же:
// Исходный интерфейс
interface Repository {
find(id: number): User;
save(user: User): void;
}
// Переопределяем
interface MyRepository extends Omit<Repository, 'find' | 'save'> {
find(id: string): MyUser; // id теперь строка, возвращаем MyUser
save(user: MyUser): void;
}
Почему это лучший способ?
- Безопасность типов: все остальные свойства остаются строго типизированными.
- Простота: не нужно копировать весь интерфейс вручную.
- Гибкость: можно переопределить любое количество свойств.
- Совместимость: работает с любыми интерфейсами, в том числе из d.ts файлов.
Этот подход используется в реальных проектах (например, в коде Angular, React, NestJS) и является стандартным решением данной проблемы.
<<Альтернативный подход: использовать пересечение типов (Intersection Types)
Вместо наследования интерфейса можно создать новый тип через пересечение (оператор &). Этот способ короче, но имеет один важный нюанс:
import { User } from 'some-library';
type MyUser = Omit<User, 'id'> & { id: string };
// Или более короткая запись:
type MyUser = Omit<User, 'id'> & { id: string };
Разница между interface extends и type &:
typeне поддерживает декларативное слияние — если вы попытаетесь объявить два типа с одним именем, будет ошибка.interfaceможет быть расширен позже (черезextendsили слияние).- Для большинства случаев использовать
typeдаже проще и читаемее.
Когда выбирать этот способ?
- Если вы не планируете расширять интерфейс в будущем.
- Если вам нужно быстро переопределить свойства для конкретной функции или переменной.
- Если вы предпочитаете работать с типами, а не интерфейсами (это дело вкуса).
Предостережение
Будьте осторожны с пересечением типов, если в оригинальном интерфейсе есть опциональные свойства (?) или свойства, которые могут быть undefined. Иногда пересечение может вести себя неожиданно при проверке совместимости типов. В таких случаях лучше использовать Omit с наследованием интерфейса, как описано в первом ответе.