How to get TSC to narrow a union type between a function and a Record with a discriminatory property and retain a proper error message

huangapple go评论71阅读模式
英文:

How to get TSC to narrow a union type between a function and a Record with a discriminatory property and retain a proper error message

问题

以下是翻译好的部分:


Edit: Comments on this post might not make sense to new readers because of the vast amount of changes that the post went through over its lifetime. Refer to the history if you are confused.

Here are some types which we will refer to in the question (find on tsplayground):

type AnyAction =
  { type: "INC"; } |
  { type: "DEC"; } |
  { type: "SET_VALUE"; payload: number; } |
  { type: "SOME_OTHER_ACTION"; hasExtra: boolean; }

type ThunkAction = () => Promise<any>

type ActionCreator = () => AnyAction | ThunkAction 

The idea behind ActionCreator is to provide a uniform approach to creating "actions" that will be dispatched to the Redux store (which is implemented to allow for thunks). A user would always "invoke" an action creator and dispatch the result to the store. The result as such can be either AnyAction or ThunkAction.

However, there are weird behaviours when we define ActionCreator in such a way, and they seem to appear due to a union between a AnyAction and ThunkAction as TSC is not able to properly narrow down what is the return type of an ActionCreator (at least to my understanding)

For example:

const good: ActionCreator = () => ({ type: "SET_VALUE", payload: 123 })
const alsoWorks: ActionCreator = () => () => Promise.resolve(123)

This works well, TSC even has good autocomplete, suggests the types, constraints the payload to a number, etc.

To showcase what is an expected (or rather, WANTED) behavior I will also demonstrate what happens once we DO NOT USE a union:

// We get a good error message
const noUnion: () => AnyAction = () => ({ type: "WRONG" })
// Type '"WRONG"' is not assignable to type '"INC" | "DEC" | "SET_VALUE" | "SOME_OTHER_ACTION"'.

However once we have a union between AnyAction and ThunkAction as a return type for our ActionCreator the error message gets some weird behaviour:

// TSC recognizes this as an error, but gives a less then ideal message
const oops: ActionCreator = () => ({ type: "WRONG" })
// Type '"WRONG"' is not assignable to type '"SOME_OTHER_ACTION"'.

Question:

How would I define the ActionCreator or (as discussed in comments) change the ThunkAction type in order to allow TSC to give a message as shown in the "showcase" even for a union return type for an ActionCreator?

英文:

Edit: Comments on this post might not make sense to new readers because of the vast amount of changes that the post went through over its lifetime. Refer to the history if you are confused.

Here are some types which we will refer to in the question (find on tsplayground):

type AnyAction =
  { type: &quot;INC&quot;; } |
  { type: &quot;DEC&quot;; } |
  { type: &quot;SET_VALUE&quot;; payload: number; } |
  { type: &quot;SOME_OTHER_ACTION&quot;; hasExtra: boolean; }

type ThunkAction = () =&gt; Promise&lt;any&gt;

type ActionCreator = () =&gt; AnyAction | ThunkAction 

The idea behind ActionCreator is to provide a uniform approach to creating "actions" that will be dispatched to the Redux store (which is implemented to allow for thunks). A user would always "invoke" an action creator and dispatch the result to the store. The result as such can be either AnyAction or ThunkAction.

However, there are weird behaviours when we define ActionCreator in such a way, and they seem to appear due to a union between a AnyAction and ThunkAction as TSC is not able to properly narrow down what is the return type of an ActionCreator (at least to my understanding)

For example:

const good: ActionCreator = () =&gt; ({ type: &quot;SET_VALUE&quot;, payload: 123 })
const alsoWorks: ActionCreator = () =&gt; () =&gt; Promise.resolve(123)

This works well, TSC even has good autocomplete, suggests the types, constraints the payload to a number, etc.

To showcase what is an expected (or rather, WANTED) behavior I will also demonstrate what happens once we DO NOT USE a union:

// We get a good error message
const noUnion: () =&gt; AnyAction = () =&gt; ({ type: &quot;WRONG&quot; })
// Type &#39;&quot;WRONG&quot;&#39; is not assignable to type &#39;&quot;INC&quot; | &quot;DEC&quot; | &quot;SET_VALUE&quot; | &quot;SOME_OTHER_ACTION&quot;&#39;

However once we have a union between AnyAction and ThunkAction as a return type for our ActionCreator the error message gets some weird behaviour:

// TSC recognizes this as an error, but gives a less then ideal message
const oops: ActionCreator = () =&gt; ({ type: &quot;WRONG&quot; })
// Type &#39;&quot;WRONG&quot;&#39; is not assignable to type &#39;&quot;SOME_OTHER_ACTION&quot;&#39;.

Question:

How would I define the ActionCreator or (as discussed in comments) change the ThunkAction type in order to allow TSC to give a message as shown in the "showcase" even for a union return type for an ActionCreator?

答案1

得分: 2

在你的代码中,AnyAction 是一个判别联合类型,但AnyAction | ThunkAction 不是,因为 ThunkAction 没有明确提到 type 判别符。它是一个联合类型,但不是判别联合类型。

TypeScript 在有判别联合类型的无效值方面通常表现得相当不错,但当你有一个任意联合类型的无效值时,错误有时不符合人们的期望。请参考microsoft/TypeScript#37488microsoft/TypeScript#53258来查看示例。

根本问题在于,如果一个值应该是类型 A | B | C | ⋯ | Z 但不是,那是因为该值不是 A 并且 它不是 B 并且 它不是 C,依此类推。如果你修复了其中一个失败,错误就会消失。但编译器不可能编写一个详细列出所有失败的错误消息(例如,“你的值不是 A,因为你在应该放置 number 的地方放置了 string 并且 它不是 B,因为你忘记了 b 属性 并且并且 它不是 Z,因为它不是一个数组,所以你应该修复其中一个。”)。相反,它只选择联合成员之一并为该成员提供错误,通常是最后一个成员(例如,“你的值不是 Z,因为它不是一个数组”)。这条错误消息并没有 necessarily incorrect(不正确);毕竟,如果你修复了那个问题,错误就会消失。只是它可能不是某人试图理解出了什么问题的最相关信息。


在没有改进错误消息的语言更改的情况下,你可以采取的一种方法是通过确保 ThunkAction 的声明明确禁止具有定义的 type 值来将 AnyAction | ThunkAction 转化为真正的判别联合。例如,可以这样做:

type ThunkAction = { (): Promise<any>, type?: never }

(这相当于(() => Promise<any>) & {type?: never},但它封装在一个单一类型中。)请注意,你无法真正禁止 TypeScript 中的属性;你能做的最接近的是创建一个可选属性,其值是不可能的 never 类型,因此唯一可能存在的值是 undefined

一旦我们这样做了,我们会得到一个更可读的错误:

const oops: ActionCreator = () => ({ type: "WRONG" });
// Type '"WRONG"' is not assignable to type 
// '"INC" | "DEC" | "SET_VALUE" | "SOME_OTHER_ACTION" | undefined'.

由于"WRONG"不是可能的判别类型之一,现在包括undefined,因此编译器可以发出一个错误,说明这一点。希望这不会改变代码的其他地方的语义。你的示例仍然可以正确工作,至少是这样:

const alsoWorks: ActionCreator = () => () => Promise.resolve(123) // okay

在 Playground 中查看代码

英文:

In your code, AnyAction is a discriminated union type, but AnyAction | ThunkAction is not because ThunkAction doesn't explicitly mention the type discriminant. It's a union, but not a discriminated union.

TypeScript tends to do a reasonably good job of producing a readable error if you have an invalid value of a discriminated union type, but when you have an invalid value of an arbitrary union, then the error is sometimes not what people expect. See microsoft/TypeScript#37488 and microsoft/TypeScript#53258 for examples.

The underlying issue is that, if a value is supposed to be of type A | B | C | ⋯ | Z but fails to be, it's because the value fails to be an A and it fails to be a B and it fails to be a C, et cetera. If you fixed even one of those failures, the error would go away. But the compiler can't possibly write an error message that details all of those (e.g., "your value isn't an A because you put a string where you were supposed to put a number and it isn't a B because you forgot the b property and ... and it isn't a Z because it's not an array, so you should fix one of these.") Instead it just picks one of the union members and gives you the error for that one, usually the last one, (e.g., "your value isn't a Z because it it's not an array"). There's nothing necessarily incorrect about that message; after all, if you fixed that, the error would go away. It's just that it might not be the most relevant piece of information for someone trying to understand what went wrong.


In the absence of language changes to improve error messages, one approach you could take would be to turn AnyAction | ThunkAction into a genuine discriminated union by making sure that ThunkAction's declaration explicitly prohibits having a defined type value. Like this, for example:

type ThunkAction = { (): Promise&lt;any&gt;, type?: never }

(That's the equivalent of (() =&gt; Promise&lt;any&gt;) &amp; {type?: never}, but it's packaged in a single type.) Note that you can't really prohibit a property in TypeScript; the closest you can come is to make an optional property whose value is the impossible never type, so the only value that could possibly be there is undefined.

Once we do that, we get a more readable error:

const oops: ActionCreator = () =&gt; ({ type: &quot;WRONG&quot; });
// Type &#39;&quot;WRONG&quot;&#39; is not assignable to type 
// &#39;&quot;INC&quot; | &quot;DEC&quot; | &quot;SET_VALUE&quot; | &quot;SOME_OTHER_ACTION&quot; | undefined&#39;.

Since &quot;WRONG&quot; is none of possible discriminant types, which now includes undefined, the compiler can issue an error saying that. And hopefully this doesn't change the semantics of the code elsewhere. Your examples still work correctly, at least:

const alsoWorks: ActionCreator = () =&gt; () =&gt; Promise.resolve(123) // okay

Playground link to code

huangapple
  • 本文由 发表于 2023年7月27日 21:24:11
  • 转载请务必保留本文链接:https://go.coder-hub.com/76780219.html
匿名

发表评论

匿名网友

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen:

确定