TypeScript: как сделать так, чтобы общий тип выводился внутри функции?

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

Вот пример, демонстрирующий проблему (обратите внимание на FIXME):

type NewsId = number

type DbRequestKind =
    | 'DbRequestGetNewsList'
    | 'DbRequestGetNewsItemById'

type DbRequest<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never;

type DbResponse<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? number[]
    : K extends 'DbRequestGetNewsItemById' ? number
    : never

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    if (req.kind === 'DbRequestGetNewsList') {
        const result = [10,20,30]
        return result as DbResponse<K> // FIXME doesn’t check valid K
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        // FIXME “Property 'newsId' does not exist on type 'DbRequest<K>'.”
        // const result = req.newsId + 10
        const result = 10
        return result as DbResponse<K> // FIXME doesn’t check valid K
    } else {
        throw new Error('Unexpected kind!')
    }
}

{
    const x = dbQuery({ kind: 'DbRequestGetNewsList' })

    // Check that response type is inferred
    const y: typeof x = [10]
    // const z: typeof x = 10 // fails (as intended, it’s good)

    console.log('DB response (list):', x);
}

{
    const x = dbQuery({ kind: 'DbRequestGetNewsItemById', newsId: 5 })

    // Check that response type is inferred
    // const y: typeof x = [10] // fails (as intended, it’s good)
    const z: typeof x = 10

    console.log('DB response (item by id):', x);
}

Это просто копия, взятая с https://github.com/unclechu/typescript-dependent-types-experiment/blob/master/index.ts. Как видите, это пример зависимой типизации. Я хочу, чтобы тип возврата DbResponse<K> зависел от аргумента функции DbRequest<K>.

Давайте посмотрим на FIXME s:

  1. Пример:

    if (req.kind === 'DbRequestGetNewsList') {
        return [10,20,30]
    }
    

    Не удается: Type 'number[]' is not assignable to type 'DbResponse<K>'.

    Or:

    if (req.kind === 'DbRequestGetNewsItemById') {
        return 10
    }
    

    Не удается: Type 'number' is not assignable to type 'DbResponse<K>'.

    Но я явно проверяю вид, и вы можете видеть условие: K extends 'DbRequestGetNewsList' ? number[], а также K extends 'DbRequestGetNewsItemById' ? number.

    В этом примере вы можете видеть, что я преобразовываю эти возвращенные значения к универсальному типу (as DbResponse<K>), но это убивает типы. Например, я могу сделать это:

    if (req.kind === 'DbRequestGetNewsList') {
        return 10 as DbResponse<K>
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        return [10,20,30] as DbResponse<K>
    }
    

    Что совершенно неверно, и программа проверки типов просто проглатывает это без звука.

  2. Следующее, что вы видите, это Property 'newsId' does not exist on type 'DbRequest<K>'..

    На самом деле это можно исправить, используя тип суммы для DbRequest<K> вместо условий типа. Но это создало бы еще одну проблему, когда вызов dbQuery снова вернул бы общий тип вместо его вывода, таким образом:

    const x = dbQuery({ kind: 'DbRequestGetNewsList' })
    const y: typeof x = [10]
    const z: typeof x = 10 // FIXME This must fail but it doesn’t with sum-type!
    

Я считаю, что эти две проблемы связаны с одним и тем же источником, с тем фактом, что K внутри тела dbQuery функции не может быть выведено даже после явной проверки if-условия для одного конкретного K. Это действительно нелогично. Работает ли это для любого случая, но не для дженериков? Могу ли я как-то преодолеть это и заставить программу проверки типов выполнять свою работу?

UPD # 1

Даже тестера написать невозможно:

function proveDbRequestGetNewsListKind<K extends DbRequestKind>(
    req: DbRequest<K>
): req is DbRequest<'DbRequestGetNewsList'> {
    return req.kind === 'DbRequestGetNewsList'
}

Это не работает с:

A type predicate's type must be assignable to its parameter's type.
  Type '{ kind: "DbRequestGetNewsList"; }' is not assignable to type 'DbRequest<K>'.

UPD # 2

Изначально мое решение было построено на перегрузках. Это не решает проблемы. См. https://stackoverflow.com/a/66119805/774228

Учти это:

function dbQuery(req: DbRequest): number[] | number {
    if (req.kind === 'DbRequestGetNewsList') {
        return 10
    } else if (req.kind === 'DbRequestGetNewsItemById') {
        return [10,20,30]
    } else {
        throw new Error('Unexpected kind!')
    }
}

Этот код не работает. Тем не менее, программа проверки типов не против.

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

Помимо этого, вам нужно вручную предоставлять все больше и больше перегрузок для каждого типа (как в Go, да).

UPD # 3

Я немного улучшил проверку типов, добавив замыкание с приведением типов. Это далеко не идеально, но лучше.

function dbNewsList(
    req: DbRequest<'DbRequestGetNewsList'>
): DbResponse<'DbRequestGetNewsList'> {
    return [10, 20, 30]
}

function dbNewsItem(
    req: DbRequest<'DbRequestGetNewsItemById'>
): DbResponse<'DbRequestGetNewsItemById'> {
    return req.newsId + 10
}

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    return (req => {
        if (req.kind === 'DbRequestGetNewsList') {
            return dbNewsList(req)
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            return dbNewsItem(req)
        } else {
            throw new Error('Unexpected kind!')
        }
    })(
        req as DbRequest<'DbRequestGetNewsList' | 'DbRequestGetNewsItemById'>
    ) as DbResponse<K>;
}

UPD # 4

Я немного улучшил последний пример, используя хак T[K], который был предложен @jcalz ниже (см. https://stackoverflow.com/a/66127276). Нет необходимости в дополнительных функциях для каждого kind.

type NewsId = number

type DbRequestKind = keyof DbResponseMap

type DbRequest<K extends DbRequestKind>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never

interface DbResponseMap {
    DbRequestGetNewsList: number[]
    DbRequestGetNewsItemById: number
}

type DbResponse<K extends DbRequestKind> = DbResponseMap[K]

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
    return (req => {
        if (req.kind === 'DbRequestGetNewsList') {
            const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
            return result
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            const result: DbResponseMap[typeof req.kind] = req.newsId + 10
            return result
        } else {
            const _: never = req
            throw new Error('Unexpected kind!')
        }
    })(req as DbRequest<DbRequestKind>) as DbResponse<K>
}

UPD # 5

Еще одно улучшение. Я добавил дополнительное ограничение для возвращаемого типа закрытия. Также я уменьшил количество лишних сущностей в шаблоне.

type NewsId = number

type DbRequest<K extends keyof DbResponseMap>
    = K extends 'DbRequestGetNewsList'     ? { kind: K }
    : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
    : never

interface DbResponseMap {
    DbRequestGetNewsList: number[]
    DbRequestGetNewsItemById: number
}

function dbQuery<K extends keyof DbResponseMap>(req: DbRequest<K>): DbResponseMap[K] {
    return ((req): DbResponseMap[keyof DbResponseMap] => {
        if (req.kind === 'DbRequestGetNewsList') {
            const result: DbResponseMap[typeof req.kind] = [10, 20, 30]
            return result
        } else if (req.kind === 'DbRequestGetNewsItemById') {
            const result: DbResponseMap[typeof req.kind] = req.newsId + 10
            return result
        } else {
            const _: never = req
            throw new Error('Unexpected kind!')
        }
    })(req as DbRequest<keyof DbResponseMap>) as DbResponseMap[K]
}

person unclechu    schedule 09.02.2021    source источник
comment
TypeScript не имеет зависимых типов; Думаю, самое большее, что я мог сделать, это указать вам на соответствующие проблемы GitHub, такие как microsoft / TypeScript # 13995 и предположите, что утверждения типа (то, что вы называете приведением), вероятно, ближе всего к желаемому поведению, потому что компилятор не может проверить безопасность типа за вас. Я могу посмотреть поподробнее, но мне не очень хочется вступать в драку, когда люди, кажется, здесь довольны голосами против.   -  person jcalz    schedule 09.02.2021
comment
@jcalz Спасибо за ответ, приятно хотя бы получить ответ, что в настоящий момент это технически невозможно. Хотя я не понимаю, что меня беспокоят голоса против. Если вам не нравится, как они работают, виноваты ТАК. Я имею в виду, что голос «за» означает «полезный», а голос «против» означает «бесполезный», я прав? Так люди узнают, какой ответ может решить проблему, а какой нет, верно?   -  person unclechu    schedule 09.02.2021


Ответы (2)


Как упоминалось в комментариях, TypeScript на самом деле не поддерживает зависимые типы, особенно когда речь идет о проверке типов реализации функции, сигнатура вызова которой подразумевает такую ​​зависимость. Общая проблема, с которой вы сталкиваетесь, упоминается в ряде проблем GitHub, в частности microsoft / TypeScript # 33014 и microsoft / TypeScript # 27808. В настоящее время здесь есть два основных пути: написать перегруженную функцию и быть осторожным с реализацией или использовать универсальную функцию с утверждениями типа и быть осторожным с реализацией.


Перегрузки:

На наличие перегрузок реализация намеренно проверяется более свободно, чем набор сигнатур вызовов. По сути, пока вы возвращаете значение, которое ожидает хотя бы одна из сигнатур вызовов, для этого возвращаемого значения не будет ошибки. Как вы видели, это небезопасно. Оказывается, TypeScript не является полностью безопасным или надежным; на самом деле это явно не цель разработки языка TypeScript. См. нецелевое # 3:

  1. Примените звуковую или доказуемо правильную систему шрифтов. Вместо этого найдите баланс между правильностью и производительностью.

При реализации перегруженной функции команда TS предпочитает продуктивность правильности. По сути, задача разработчика - гарантировать безопасность типов; компилятор на самом деле не пытается это сделать.

См. microsoft / TypeScript # 13235 для получения дополнительной информации. Было предложено отлавливать такие ошибки, но предложение было закрыто как слишком сложное. Правильное выполнение перегрузок потребует от компилятора гораздо больше работы, и нет достаточных доказательств того, что такого рода ошибки совершаются достаточно часто, чтобы оправдать добавленную сложность и снижение производительности.


Общие функции:

Проблема здесь в некотором роде противоположная; компилятор не может увидеть, что реализация безопасна, и выдаст вам ошибку практически для всего, что вы вернете. Анализ потока управления не сужает неразрешенный параметр универсального типа или значение неразрешенного универсального типа. Вы можете проверить req.kind, но компилятор не использует это для каких-либо действий с типом K. Возможно, вы не можете сузить K, проверив значение типа K, потому что это может быть полное объединение.

См. microsoft / TypeScript # 24085 для более подробного обсуждения этой проблемы. Чтобы сделать это правильно, потребуются некоторые фундаментальные изменения в способе обработки дженериков. По крайней мере, это открытый вопрос, так что есть некоторая надежда, что что-то может быть сделано в будущем, но я бы не стал на это полагаться.

Если вы хотите, чтобы компилятор принял то, что он не может проверить, вам следует дважды проверить, что вы все делаете правильно, а затем использовать type assertion, чтобы отключить предупреждение компилятора.


Для вашего конкретного примера мы можем сделать немного лучше. Одно из немногих мест, где TypeScript пытается моделировать зависимые типы, - это когда поиск типа свойства объекта по буквальному типу ключа. Если у вас есть значение t типа T и ключ k типа K extends keyof T, компилятор поймет, что t[k] имеет тип T[K].

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

interface DbRequestMap {
  DbRequestGetNewsList: {};
  DbRequestGetNewsItemById: { newsId: NewsId }
}
type DbRequestKind = keyof DbRequestMap;
type DbRequest<K extends DbRequestKind> = DbRequestMap[K] & { kind: K };

interface DbResponseMap {
  DbRequestGetNewsList: number[];
  DbRequestGetNewsItemById: number;
}
type DbResponse<K extends DbRequestKind> = DbResponseMap[K]

function dbQuery<K extends DbRequestKind>(req: DbRequest<K>): DbResponse<K> {
  return {
    get DbRequestGetNewsList() {
      return [10, 20, 30];
    },
    get DbRequestGetNewsItemById() {
      return 10; 
    }
  }[req.kind];
}

Здесь мы представляем DbRequest<K> как значение со свойством {kind: K} и DbResponse<K> как значение типа DbResponseMap[K]. В реализации мы создаем объект типа DbResponseMap с геттерами для предотвращения весь объект из вычисляемого, а затем найдите его свойство req.kind типа _18 _..., чтобы получить DbResponse<K>, с которым компилятор доволен.

Хотя, по большому счету, он не идеален. Внутри реализации компилятор все еще не может сузить req до всего, что имеет, скажем, свойство newsId. Таким образом, вы обнаружите, что все еще небезопасно сужаетесь:

return (req as DbRequest<DbRequestKind> as 
  DbRequest<"DbRequestGetNewsItemById">).newsId + 10; // ????

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


площадка Li nk в код

person jcalz    schedule 09.02.2021
comment
Взлом с T[K] умный. Но пока я могу делать это: get DbRequestGetNewsList() { return [(req as DbRequest<DbRequestKind> as DbRequest<'DbRequestGetNewsItemById'>).newsId + 10] }, что NaN во время выполнения, я остаюсь неудовлетворенным. Спасибо за очень хороший и подробный ответ! - person unclechu; 11.02.2021
comment
См. Раздел UPD # 4 в этой теме. Я немного улучшил свой последний пример, используя ваш T[K] хак. Я думаю, это самый безопасный образец, который только можно получить. - person unclechu; 11.02.2021
comment
Я даже добавил еще одно улучшение в раздел UPD # 5, ограничение типа возврата закрытия. Также я уменьшил количество лишних сущностей. - person unclechu; 11.02.2021

Вот вам рабочий код:

type NewsId = number

type DbRequestKind =
  | 'DbRequestGetNewsList'
  | 'DbRequestGetNewsItemById'

type DbRequest<K extends DbRequestKind>
  = K extends 'DbRequestGetNewsList' ? { kind: K }
  : K extends 'DbRequestGetNewsItemById' ? { kind: K, newsId: NewsId }
  : never;

type DbResponse<K extends DbRequestKind>
  = K extends 'DbRequestGetNewsList' ? number[]
  : K extends 'DbRequestGetNewsItemById' ? number
  : never

type Distributive<T> = [T] extends [any] ? T : never


function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsItemById'>): DbResponse<'DbRequestGetNewsItemById'>
function dbQuery<K extends DbRequestKind>(req: DbRequest<'DbRequestGetNewsList'>): DbResponse<'DbRequestGetNewsList'>
function dbQuery(req: DbRequest<DbRequestKind>): Distributive<DbResponse<DbRequestKind>> {
  if (req.kind === 'DbRequestGetNewsList') {
    const result = [10, 20, 30]
    return result // FIXME doesn’t check valid K
  } else if (req.kind === 'DbRequestGetNewsItemById') {
    const result = req.newsId + 10 // error
    //return '2' // error
    return 2 // error
  } else {
    const x = req // never
    throw new Error('Unexpected kind!')
  }
}

Имейте в виду, что K extends DbRequestKind - это не то же самое, что DbRequestKind, потому что K может быть намного шире. Это сделало трюк

person captain-yossarian    schedule 09.02.2021
comment
Я просто привел вам пример, чтобы показать, в чем проблема, но та же проблема была продемонстрирована в теме, также с перегрузками, о которых я упоминал в комментарии выше (см. Раздел UPD # 2). Мой пример компилируется, и в этом проблема. Потому что я возвращаю number[] для ответа «новость» и number для «списка новостей», что абсолютно неверно. Я продемонстрировал именно это в моем первом начальном примере, а также дополнительно в разделе UPD # 2. Я проголосовал против, потому что это не помогает решить проблему. Ни для меня, ни для кого-либо еще, голоса против предназначены именно для этой цели. - person unclechu; 09.02.2021