Перейти к содержанию

Readonly, Partial, Required, Pick, Record

Чтобы сделать повседневные будни разработчика немного легче, TypeScript, реализовал несколько предопределенных сопоставимых типов, как - Readonly<T>, Partial<T>, Required<T>, Pick<T, K> и Record<K, T>. За исключением Record<K, T>, все они являются так называемым гомоморфными типами (homomorphic types). Простыми словами, гомоморфизм — это возможность изменять функционал сохраняя первоначальные свойства всех операций. Если на данный момент это кажется сложным, то текущая глава покажет, что за данным термином не скрывается ничего сложного. Кроме того, в ней будет подробно рассмотрен каждый из перечисленных типов.

Readonly (сделать члены объекта только для чтения)

Сопоставимый тип Readonly<T> добавляет каждому члену объекта модификатор readonly, делая их тем самым только для чтения.

// lib.es6.d.ts

type Readonly<T> = {
  readonly [P in keyof T]: T[P];
};

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

interface IPerson {
  name: string;
  age: number;
}

/**
 * Функция, параметр которой не
 * защищен от случайного изменения.
 *
 * Поскольку объектные типы передаются
 * по ссылке, то с высокой долей вероятности,
 * случайное изменение поля name нарушит ожидаемый
 * ход выполнения программы.
 */
function mutableAction(person: IPerson) {
  person.name = 'NewName'; // Ok
}

/**
 * Надежная функция защищающая свои
 * парметры от изменения не требуя описания
 * нового неизменяемого типа.
 */
function immutableAction(person: Readonly<IPerson>) {
  person.name = 'NewName'; // Error -> Cannot assign to 'name' because it is a read-only property.
}

Тип сопоставления Readonly<T> является гомоморфным и добавляя свой модификатор readonly не влияет на уже существующие модификаторы. Сохранения исходным типом своих первоначальных характеристик (в данном случае — модификаторы), делает сопоставленный тип Readonly<T> гомоморфным.

interface IPerson {
  gender?: string;
}

type Person = Readonly<IPerson>; // type Person = { readonly gender?: string; }

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

/**
 * Интерфейс необходим для описания экземпляра
 * провайдеров с которыми будет сопряженно
 * приложение. Кроме того, интерфейс описывает
 * поставляемые данные как только для чтения,
 * что в будущем может сэкономить время.
 */
interface IDataProvider<OutputData, InputData = null> {
  getData(): Readonly<OutputData>;
}

/**
 * Абстрактный класс описание определяющий
 * поле data доступный только потомка как
 * только для чтения. Это позволит предатвратить
 * случайное изменение данных в классах потомках.
 */
abstract class DataProvider<InputData, OutputData = null>
  implements IDataProvider<InputData, OutputData> {
  constructor(protected data?: Readonly<OutputData>) {}

  abstract getData(): Readonly<InputData>;
}

interface IPerson {
  firstName: string;
  lastName: string;
}

interface IPersonDataProvider {
  name: string;
}

class PersonDataProvider extends DataProvider<
  IPerson,
  IPersonDataProvider
> {
  getData() {
    /**
     * Работая в теле потомков DataProvider
     * будет не так просто случайно изменить
     * данные доступные через ссылку this.data
     */
    let [firstName, lastName] = this.data.name.split(` `);
    let result = { firstName, lastName };

    return result;
  }
}

let provider = new PersonDataProvider({
  name: `Ivan Ivanov`,
});

Partial (сделать все члены объекта необязательными)

Сопоставимый тип Partial<T> добавляет членам объекта модификатор ?: делая их таким образом необязательными.

// lib.es6.d.ts

type Partial<T> = {
  [P in keyof T]?: T[P];
};

Тип сопоставления Partial<T> является гомоморфным и не влияет на существующие модификаторы, а лишь расширяет модификаторы конкретного типа.

interface IPerson {
  readonly name: string; // поле помеченно как только для чтения
}

/**
 * добавлен необязательны модификатор
 * и при этом сохранен модификатор readonly
 *
 * type Person = {
 *  readonly name?: string;
 * }
 */
type Person = Partial<IPerson>;

Представьте приложение зависящее от конфигурации, которая как полностью, так и частично, может быть переопределена пользователем. Поскольку работоспособность приложения завязана на конфигурации, члены определенные в типе представляющем её, должны быть обязательными. Но поскольку пользователь может переопределить лишь часть конфигурации, функция выполняющая её слияние с конфигурацией по умолчанию, не может указать в аннотации типа уже определенный тип, так как его члены обязательны. Описывать новый тип, слишком утомительно. В таких случаях необходимо прибегать к помощи Partial<T>.

interface IConfig {
  domain: string;
  port: '80' | '90';
}

const DEFAULT_CONFIG: IConfig = {
  domain: `https://domain.com`,
  port: '80',
};

function createConfig(config: IConfig): IConfig {
  return Object.assign({}, DEFAULT_CONFIG, config);
}

/**
 * Error -> Поскольку в типе IConfig все
 * поля обязательные, данную функцию
 * не получится вызвать с частичной конфигурацией.
 */
createConfig({
  port: '80',
});

function createConfig(config: Partial<IConfig>): IConfig {
  return Object.assign({}, DEFAULT_CONFIG, config);
}

/**
 * Ok -> Тип Partial<T> сделал все члены
 * описанные в IConfig необязательными,
 * поэтому пользователь может переопределит
 * конфигурацию частично.
 */
createConfig({
  port: '80',
});

Required (сделать все необязательные члены обязательными)

Сопоставимый тип Required<T> удаляет все необязательные модификаторы ?: приводя члены объекта к обязательным. Достигается это путем удаления необязательных модификаторов при помощи механизма префиксов - и + рассматриваемого в главе Оператор keyof, Lookup Types, Mapped Types, Mapped Types - префиксы + и -).

type Required<T> = {
  [P in keyof T]-?: T[P];
};

Тип сопоставления Required<T> является полной противоположностью типу сопоставления Partial<T>.

interface IConfig {
  domain: string;
  port: '80' | '90';
}

/**
 * Partial добавил членам IConfig
 * необязательный модификатор ->
 *
 * type T0 = {
 *  domain?: string;
 *  port?: "80" | "90";
 * }
 */
type T0 = Partial<IConfig>;

/**
 * Required удалил необязательные модификаторы
 * у типа T0 ->
 *
 * type T1 = {
 *  domain: string;
 *  port: "80" | "90";
 * }
 */
type T1 = Required<T0>;

Тип сопоставления Required<T> является гомоморфным и не влияет на модификаторы отличные от необязательных.

interface IT {
  readonly a?: number;
  readonly b?: string;
}

/**
 * Модификаторы readonly остались
 * на месте ->
 *
 * type T0 = {
 *  readonly a: number;
 *  readonly b: string;
 * }
 */
type T0 = Required<IT>;

Pick (отфильтровать объектный тип)

Сопоставимый тип Pick<T, K> предназначен для фильтрации объектного типа ожидаемого в качестве первого параметра типа. Фильтрация происходит на основе ключей представленных множеством литеральных строковых типов ожидаемых в качестве второго параметра типа.

// lib.es6.d.ts

type Pick<T, K extends keyof T> = {
  [P in K]: T[P];
};

Простыми словами, результатом преобразования Pick<T, K> будет являться тип состоящий из членов первого параметра идентификаторы которых указанны во втором параметре.

interface IT {
  a: number;
  b: string;
  c: boolean;
}

/**
 * Поле "с" отфильтрованно ->
 *
 * type T0 = { a: number; b: string; }
 */
type T0 = Pick<IT, 'a' | 'b'>;

Стоит заметить, что в случае указания несуществующих ключей возникнет ошибка.

interface IT {
  a: number;
  b: string;
  c: boolean;
}

/**
 * Error ->
 *
 * Type '"a" | "U"' does not satisfy the constraint '"a" | "b" | "c"'.
 * Type '"U"' is not assignable to type '"a" | "b" | "c"'.
 */
type T1 = Pick<IT, 'a' | 'U'>;

Тип сопоставления Pick<T, K> является гомоморфным и не влияет на существующие модификаторы, а лишь расширяет модификаторы конкретного типа.

interface IT {
  readonly a?: number;
  readonly b?: string;
  readonly c?: boolean;
}

/**
 * Модификаторы readonly и ? сохранены ->
 *
 * type T2 = { readonly a?: number; }
 */
type T2 = Pick<IT, 'a'>;

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

function pick<T, K extends string & keyof T>(
  object: T,
  ...keys: K[]
) {
  return Object.entries(object) // преобразуем объект в массив [идентификатор, значение]
    .filter(([key]: Array<K>) => keys.includes(key)) // фильтруем
    .reduce(
      (result, [key, value]) => ({
        ...result,
        [key]: value,
      }),
      {} as Pick<T, K>
    ); // собираем объект из прошедших фильтрацию членов
}

let person = pick(
  {
    a: 0,
    b: ``,
    c: true,
  },
  `a`,
  `b`
);

person.a; // Ok
person.b; // Ok
person.c; // Error -> Property 'c' does not exist on type 'Pick<{ a: number; b: string; c: boolean; }, "a" | "b">'.

Record (динамически определить поле в объектном типе)

Сопоставимый тип Record<K, T> предназначен для динамического определения полей в объектном типе. Данный тип определяет два параметра типа. В качестве первого параметра ожидается множество ключей представленных множеством string или Literal String - Record<"a", T> или Record<"a" | "b", T>. В качестве второго параметра ожидается конкретный тип данных, который будет ассоциирован с каждым ключом.

// lib.es6.d.ts

type Record<K extends string, T> = {
  [P in K]: T;
};

Самый простой пример, который первым приходит в голову, это замена индексных сигнатур.

/**
 * Поле payload определенно как объект
 * с индексной сигнатурой, что позволит
 * динамически записывать в него поля.
 */
interface IConfigurationIndexSignature {
  payload: {
    [key: string]: string;
  };
}

/**
 * Поле payload определенно как
 * Record<string, string> что аналогично
 * предыдущему варианту, но выглядит более
 * декларативно.
 */
interface IConfigurationWithRecord {
  payload: Record<string, string>;
}

let configA: IConfigurationIndexSignature = {
  payload: {
    a: `a`,
    b: `b`,
  },
}; // Ok
let configB: IConfigurationWithRecord = {
  payload: {
    a: `a`,
    b: `b`,
  },
}; // Ok

Но в отличии от индексной сигнатуры типа Record<K, T> может ограничить диапазон ключей.

type WwwConfig = Record<'port' | 'domain', string>;

let wwwConfig: WwwConfig = {
  port: '80',
  domain: 'https://domain.com',

  user: 'User', // Error -> Object literal may only specify known properties, and 'user' does not exist in type 'Record<"port" | "domain", string>'.
};

В занном случае было бы даже более корректным использовать Record<K, T> в совокупности с ранее рассмотренным типом Partial<T>.

type WwwConfig = Partial<Record<'port' | 'domain', string>>;

let wwwConfig: WwwConfig = {
  port: '80',
  // Ok -> поле domain теперь не обязательное
  user: 'User', // Error -> Object literal may only specify known properties, and 'user' does not exist in type 'Record<"port" | "domain", string>'.
};

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

/**
 * Ok -> поле a ассоциированно с таким
 * же типом что указан в индексной сигнатуре.
 */
interface T0 {
  a: number;

  [key: string]: number;
}

/**
 * Error -> тип поля a не совпадает с типом
 * указанным в индексной сигнатуре.
 */
interface T1 {
  a: string; // Error -> Property 'a' of type 'string' is not assignable to string index type 'number'.

  [key: string]: number;
}

Данный пример можно переписать с использованием типа пересечения.

interface IValue {
  a: number;
}

interface IDynamic {
  [key: string]: string;
}

type T = IDynamic & IValue;

/**
 * Error ->
 * Type '{ a: number; }' is not assignable to type 'IDynamic'.
 * Property 'a' is incompatible with index signature.
 * Type 'number' is not assignable to type 'string'.
 */
let t: T = {
  a: 0,
};

Аналогичное поведение будет и для пересечения определяемого типом Record<K, T>.

interface IValue {
  a: number;
}

type T = Record<string, string> & IValue;

/**
 * Error ->
 * Type '{ a: number; }' is not assignable to type 'Record<string, string>'.
 * Property 'a' is incompatible with index signature.
 * Type 'number' is not assignable to type 'string'.
 */
let t: T = {
  a: 0,
};