Как сузить типы для объекта T, передав массив исключений (keyof T)[]?

#typescript #typescript-typings

#typescript #typescript-типизации

Вопрос:

Как я могу описать тип, который можно использовать в качестве средства защиты типа для исключения ключей из объекта?

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

Я уже использовал защиту типов в filter выражениях раньше, но никогда из предопределенного массива.

 interface Foo {
  id: number
  val1: string
  val2: string
}

type KeysOf<T> = (keyof T)[]
const getKeys = <T> (obj: T) => Object.keys(obj) as KeysOf<T>
const getExcludedKeys = <T> (obj: T, excludeKeys: KeysOf<T>) =>
  getKeys(obj)
    .filter((key): key is Exclude<keyof T, typeof excludeKeys> => { // This line isn't working as expected.
      return !excludeKeys.includes(key)
    })


const foo: Foo = {
  id: 1,
  val1: 'val1',
  val2: 'val2'
}

const result = getExcludedKeys(foo, ['val1', 'val2'])
  .map(key => key)  // EXPECTED :: key: "id"
                    // ACTUAL   :: key: "id" | "val1" | "val2"
  

Ответ №1:

Вы дали самостоятельный ответ, а затем назначили награду за свой вопрос и добавили этот комментарий к своему ответу:

Все это прерывается, если массив исключенных ключей определен в переменной / константе перед передачей в exclude…

Итак, я предполагаю, что это проблема, которая вас беспокоит. Действительно, если я возьму ваш код и сделаю это, я получу ошибку во втором аргументе exclude :

 const toExclude = ["foo", "bar"];
const b = exclude(a, toExclude)
    .map(key => {                   // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
        if (key === 'bar') { }      // Error
        if (key === 'foo') { }      // Error
        if (key === 'baz') { }      // Okay
        if (key === 'yay') { }      // Okay
    });
  

Ваша проблема в том, что при объявлении переменной, подобной этой:

 const toExclude = ["foo", "bar"];
  

TypeScript определит «наилучший распространенный тип» (эта терминология взята из документации). «Наилучший распространенный тип» — тип, который соответствует наиболее распространенным сценариям использования. Обычно, когда кто-то определяет массив, содержащий строковые литералы, желаемый выводимый тип является string[] . Это похоже на то, как при выполнении const x = "moo"; выводятся типы x is string и not "moo" . Если вам нужен более узкий тип для него, вам нужно быть явным const x: "moo" = "moo" . И аналогично const x = 1 будет выполнен вывод number для x , хотя он мог бы иметь более узкий тип.

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

 const toExclude = ["foo", "bar"] as ["foo", "bar"];
  

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

 function asKeysOfA<T extends (keyof typeof a)[]>(...values: T) { return values; }

const toExclude = asKeysOfA("foo", "bar");
  

toExclude предполагается, что приведенное выше значение имеет тип ["foo", "bar"] . Вот версия, которая будет работать с любым типом объекта, а не только typeof a :

 function asKeysOf<O, T extends (keyof O)[]>(o: O, ...values: T) { return values; }

const toExclude = asKeysOf(a, "foo", "bar");
  

В этом случае вам нужно передать a в качестве первого аргумента, чтобы T можно было правильно вывести. a функция не используется.

Вам может быть интересно, почему, когда вы это делаете exclude(a, ["foo", "bar"]) , вам не нужно выполнять утверждение типа, потому что при выводе типа 2-го аргумента TS использует метод, отличный от «наилучшего общего типа». Он определяет «контекстный тип». Если структура может удовлетворять типу, объявленному для 2-го аргумента exclude , то TS выводит этот тип, а не более общий тип, и вам не нужно использовать утверждение типа.

На самом деле это то, что используют вышеприведенные функции asKeysOfA и asKeysOf : когда значения передаются в качестве параметров этим функциям, TS выводит для них контекстный тип, который затем переносится при выводе возвращаемых значений вспомогательных функций.

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

 function getAllKeys<T>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

function exclude<T, K extends (keyof T)[]>(obj: T, excludes: K) {
    return getAllKeys(obj)
        .filter((key: keyof T): key is Exclude<keyof T, K[number]> =>
                !excludes.includes(key));
}

const a = {
    foo: 'abc',
    bar: 'abc',
    baz: 'abc',
    yay: true
};

// function asKeysOfA<T extends (keyof typeof a)[]>(...values: T) { return values; }

// const toExclude = asKeysOfA("foo", "bar");

function asKeysOf<O, T extends (keyof O)[]>(o: O, ...values: T) { return values; }

const toExclude = asKeysOf(a, "foo", "bar");

const b = exclude(a, toExclude)
    .map(key => {                   // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
        if (key === 'bar') { }      // Error
        if (key === 'foo') { }      // Error
        if (key === 'baz') { }      // Okay
        if (key === 'yay') { }      // Okay
    });
  

Скоро выйдет TypeScript 3.4, который предоставляет некоторые новые функциональные возможности, влияющие на то, что вы пытаетесь здесь сделать. В TypeScript 3.4 можно попросить компилятор определить максимально узкий тип для литеральных значений, где в противном случае он бы определил «наилучший распространенный тип». Вы должны использовать утверждение типа as const . Таким образом, toExclude массив может быть объявлен следующим образом:

 const toExclude = ["foo", "bar"] as const;
  

Обратите внимание, что это также создает массив readonly . Следовательно, объявление exclude также должно ожидать readonly массив, поэтому определение K должно измениться на K extends readonly ... :

 function exclude<T, K extends readonly (keyof T)[]>(obj: T, excludes: K) {
  

Вот пример:

 function getAllKeys<T>(obj: T): (keyof T)[] {
  return Object.keys(obj) as (keyof T)[];
}

function exclude<T, K extends readonly (keyof T)[]>(obj: T, excludes: K) {
    return getAllKeys(obj)
        .filter((key: keyof T): key is Exclude<keyof T, K[number]> =>
                !excludes.includes(key));
}

const a = {
    foo: 'abc',
    bar: 'abc',
    baz: 'abc',
    yay: true
};

const toExclude = ["foo", "bar"] as const;

const b = exclude(a, toExclude)
    .map(key => {                   // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
      if (key === 'bar') { }      // Error
      if (key === 'foo') { }      // Error
      if (key === 'baz') { console.log("Q"); }      // Okay
      if (key === 'yay') {console.log("F"); }      // Okay
    });
  

Этот пример ведет себя так, как ожидалось с Typescript 3.4.0-rc

Комментарии:

1. Спасибо, что нашли время описать, как работает вывод типов, однако это не решает мою проблему. Я подумал, что должен был существовать способ описания типа, который позволил бы мне передать заранее определенный массив ключей toExclude без вызова функции (asKeysOf), вызов функции ради вывода типов не кажется отличным решением.

2. Как я показал перед показом служебных функций, вы можете сделать это без служебной функции, используя утверждение типа, подобное const toExclude = ["foo", "bar"] as ["foo", "bar"]; . Что касается остального, если я правильно определил конкретный сценарий, с которым вы столкнулись, то единственное, что я могу сказать, это то, что из-за ограничений в TS иногда единственный способ использовать вывод типа для получения желаемого результата — это использовать вспомогательную функцию.

3. TS 3.4 выглядит многообещающе, еще раз спасибо за ваш подробный ответ!

Ответ №2:

После нескольких часов возни с ним мне наконец удалось взломать его с небольшой помощью @nucleartux с его типом Omit.

Все, что мне было нужно, это этот дурацкий тип Omit<T, K extends KeysList<T>> = Exclude<keyof T, K[number]> в качестве защиты типа в сочетании с exclude наличием второго универсального типа K extends (keyof T)[]

 type KeysList<T> = (keyof T)[]
type Omit<T, K extends KeysList<T>> = Exclude<keyof T, K[number]>

function getAllKeys<T>(obj: T): KeysList<T> {
    return Object.keys(obj) as KeysList<T>
}

function exclude<T, K extends KeysList<T>>(obj: T, excludes: K) {
    const filterCallback = (key: keyof T): key is Omit<T, K> => // <-- THIS LINE
        !excludes.includes(key) 

    return getAllKeys(obj)
        .filter(filterCallback)
}

const a = {
    foo: 'abc',
    bar: 'abc',
    baz: 'abc',
    yay: true
};

const b = exclude(a, ['foo', 'bar'])
    .map(key => {                   // (EXPECTED :: key: 'baz') (ACTUAL :: key: 'foo' | 'bar' | 'baz')
        if (key === 'bar') { }      // Error
        if (key === 'foo') { }      // Error
        if (key === 'baz') { }      // Okay
        if (key === 'yay') { }      // Okay
    });
  

Комментарии:

1. Все это прерывается, если массив исключенных ключей определен в переменной / константе перед передачей в exclude