Typescript - защита общего типа для функции isEmpty

Я не могу правильно реализовать общий isEmpty(value) с точки зрения ограничения типа сужения предоставленного значения до его пустого аналога.

Пример использования:

function getCountryNameById(countries: LookupItem[] = [], countryId?: number): string | undefined {

  if (isEmpty(countries) || !isNumber(countryId)) {

    // within this branch I would like to get countries argument to be narrowed to empty array type. 
    // Same would apply for other function which can have argument type of object or string. Why ? -> to prevent someone to do some mad code hacks like accessing non existent value from empty array ( which would happen on runtime ofc ) on compile time
    // $ExpectType []
    console.log(countries)

    return
  }

  // continue with code logic ...
  // implementation ...
}

аналогичный случай на ограничивающем объекте:

function doSomethingWithObject( data: { foo: string; bar: number } | object ){ 
   if(isEmpty(data)){
     // $ExpectType {}
     data

     // following should throw compile error, as data is empty object
     data.foo.toUpercase()

     return
   }

   // here we are sure that data is not empty on both runtime and compile time
}

Реализация защиты типа isEmpty:

export const isEmpty = <T extends AllowedEmptyCheckTypes>(
  value: T | AllowedEmptyCheckTypes
): value is Empty<T> => {
  if (isBlank(value)) {
    return true
  }

  if (isString(value) || isArray(value)) {
    return value.length === 0
  }

  if (isObject(value)) {
    return Object.keys(value).length === 0
  }

  throw new Error(
    `checked value must be type of string | array | object. You provided ${typeof value}`
  )
}

С определенными типами:

type EmptyArray = Array<never>
type Blank = null | undefined | void

/**
 * // object collects {} and Array<any> so adding both {} and Array<any> is not needed
 * @private
 */
export type AllowedEmptyCheckTypes = Blank | string | object

/**
 * Empty mapped type that will cast any AllowedEmptyCheckTypes to empty equivalent
 * @private
 */
export type Empty<T extends AllowedEmptyCheckTypes> = T extends string
  ? ''
  : T extends any[]
    ? EmptyArray
    : T extends object ? {} : T extends Blank ? T : never

Это как-то странно, поскольку с точки зрения типов он сужается правильно, но не внутри ветки if / else:

isEmpty для строковых значений

isEmpty для значений массива

isEmpty для значений объекта

код можно увидеть здесь: https://github.com/Hotell/rex-tils/pull/13/files#diff-a3cdcb321a05315fcfc3309031eab1d8R177

Связанный вопрос: Type Guard для пустого объекта


person hotell    schedule 05.09.2018    source источник
comment
У меня проблемы с этим. Не могли бы вы включить сюда весь соответствующий код в виде кода (не изображения кода или ссылки на код), удалить все, что не требуется для воспроизведения проблемы, и четко указать, где находится ваша проблема (что вы ожидаете по сравнению с тем, что вы получаете)? Предоставление минимально воспроизводимого примера имеет большое значение для получения содержательного ответа.   -  person jcalz    schedule 05.09.2018


Ответы (3)


Один из способов решения этой проблемы - отделить нулевые проверки (undefined, null) от проверок пустых значений ('', [] {}). Я обычно использую для этого два типа охранников - isDefined и isEmpty.

Сначала это может выглядеть так. Обратите внимание на проверку typeof - она ​​работает и с необъявленными переменными.

function isDefined<T>(value: T | undefined | null): value is T {
  return (typeof value !== 'undefined') && (value !== null);
}

Для пустых значений можно использовать следующую модель.

namespace Empty {
  export type String = '';
  export type Object = Record<string, never>;
  export type Array = never[];
}

type Empty =
  | Empty.Array
  | Empty.Object
  | Empty.String;

function isEmpty<T extends string | any[] | object>(subject: T | Empty): subject is Bottom<T> {
  switch (typeof subject) {
    case 'object':
      return (Object.keys(subject).length === 0);
    case 'string':
      return (subject === '');
    default:
      return false;
  }
}

type Bottom<T> =
  T extends string
    ? Empty.String
    : T extends any[]
        ? Empty.Array
        : T extends object
            ? Empty.Object
            : never;

Нижние значения выводятся правильно.

declare const foo: 'hello' | Empty.String;
declare const bar: [number, number] | Empty.Array;
declare const baz: Window | Empty.Object;

if (isEmpty(foo) && isEmpty(bar) && isEmpty(baz)) {
  console.log(foo, bar, baz);
}

Изменить: добавлены ограничения для T, как было предложено.

person Karol Majewski    schedule 06.09.2018
comment
Правильно, я приблизился к подобному, единственное, чего мне не хватало в тесте для объекта, было явное приведение типа объекта к объединению этого объекта или пустого объекта. Я буду использовать это в будущем. огромное спасибо ! - person hotell; 06.09.2018
comment
Я бы также ограничил общий, поэтому потребитель получит ошибку времени компиляции, если он хочет поддерживать неподдерживаемый тип проверки isEmpty (он хочет ошибку в значениях объекта, но еще лучше то, что я не думаю :)) export const isEmpty = ‹T расширяет строку | объект ›(значение: T | Пусто): значение Bottom ‹T› - person hotell; 06.09.2018

Итак, после нескольких обсуждений в твиттере и долгого поиска в SO / Github я пришел к следующему решению:

  • прежде всего проверка на null / undefined в isEmpty не имеет большого смысла (хотя lodash.isEmpty обрабатывает это, ИМХО, он делает слишком много и не очень явным образом)
  • потому что между {} | object | any[] сужение защиты типа никогда не будет работать должным образом
  • окончательное решение принимает только допустимые значения для проверки в качестве аргументов -> объекты js и строка, а защита возвращает never, поэтому совпадающие значения будут иметь тип never, потому что в любом случае нет никакого смысла выполнять любой дальнейший вход в операторе if(isEmpty(value)){ ... }, а не завершать запрограммировать или выдать ошибку

вот окончательная реализация:

const isEmpty = <T extends string | object | any[]>(
  value: T
): value is never => {
  if (isString(value) || isArray(value)) {
    return value.length === 0
  }
   if (isObject(value)) {
    return Object.keys(value).length === 0
  }
   throw new Error(
    `checked value must be type of string | array | object. You provided ${
      // tslint:disable-next-line:strict-type-predicates
      value === null ? 'null' : typeof value
    }`
  )
}

https://github.com/Hotell/rex-tils/pull/13/files#diff-68ff3b6b6a1354b7277dfc4b23d99901R50

person hotell    schedule 06.09.2018

Здесь

if (isEmpty(countries) || !isNumber(countryId)) {

у вас есть два условия, только одно из которых является защитой типа для countries, поэтому тип countries не изменяется внутри if.

Что касается объектов, {} не представляет собой пустой объект. Все, кроме null и undefined, может быть присвоено переменной типа {}. Вместо этого вы можете использовать { [prop: string]: never } или { [prop: string]: undefined }.

person thorn0    schedule 05.09.2018
comment
ничего из этого не работает. Я уже пробовал их :) {[prop: keyof T]: never} или {[prop: keyof T]: undefined} - person hotell; 06.09.2018