How to properly type a structure that is amorphous but has a general shape, in order to avoid losing typeinfo and errors?

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

How to properly type a structure that is amorphous but has a general shape, in order to avoid losing typeinfo and errors?

问题

我正在尝试使用泛型定义类型,将它们作为具有形状的类型的集合,但要么我做错了什么,要么 TypeScript 无法实现它。我在过去一周尝试了很多方法,但大部分都因为一直尝试其他方法而“丢失”。我不确定是否可能实现,但我猜应该可以。我将尽量简化它,但这会是一个较长的帖子,很抱歉,这次没有TLDR

需要使用约200行仅包含类型的代码来创建一个特定问题的最小可复现示例,其中大部分都与问题无关,但由于它们都相互链接,因此很难从中提取一个简单的示例,因此我将解释目前的问题并提供一个 TypeScript Playground 的链接,以供有需要的人查看。

为了背景,我正在开发某种 Redux 扩展,或者可以说是 Redux2.0。

我正在尝试定义一个函数的“返回值”类型,该函数接受一个“Bundle”数组并返回一个基于这些“Bundle”的结果。那么什么是 Bundle 呢?它有点像“Redux 插件”,类似这样:

interface Bundle<
    S = any,
    Args extends object = object,
    ActionExt extends object = object
> {
    name: string
    reducer?: Reducer<S>
    selectors?: { [key: string]: Selector }
    reactors?: { [key: string]: Reactor }
    actions?: { [key: string]: AnyAction | ThunkAction | ActionExt | ?PossibleFutureProblem? }
    priority?: number
    init?: (store: Store) => void
    args?: ArgGenerator<Args>
    middleware?: MiddlewareGenerator<ActionExt>
    persist?: string[]
}

因此,一旦函数处理多个这些 Bundle,它应该返回一个名为 BundleComposition 的东西,看起来像这样:

interface BundleComposition {
    bundleNames: string[]
    reducers: { [key: string]: Reducer }
    selectors: { [key: string]: Selector }
    reactors: { [key: string]: Reactor }
    actions: { [key: string]: AnyAction }
    initMethods: Array<(store: Store) => void>
    args: Array<{ [I in keyof any[]]: ArgGenerator<any> }[number]>
    middleware: MiddlewareGenerator[]
    processed: Bundle[]
}

我遇到的问题有两个,所以让我们分别解决它们。

1. 泛型/默认值的错误问题

当定义这个函数时,我们会定义它为一个接受多个“Bundles”的函数,并返回一个“BundleComposition”,因此像这样定义会起作用:

type ComposeBundles = (...bundles: Bundle[]) => BundleComposition

请注意,当定义这个函数时,几乎不可能精确地定义这些 bundles 的“形状”,我们知道它们必须是一个 bundle,但当创建它时,Bundle 类型可以并且肯定会在创建时定义它的类型参数,但这个函数用于多个不同的 bundles,因此我们不能定义它所接受的“数组”的形状,因为它们既是未知的,又不是完全相同的形状。

现在,当我们定义一个 bundle 时,如下所示:

interface ICFG {
    tag: 'testconfig'
}

interface IActExt {
    specificTag: number
}

const INITIAL_STATE = {
    testState: 0,
}

// 一个简单的类型保护
const isSpecificAction = (action: any): action is IActExt => !!action.specificTag

const ExampleBundle: Bundle<typeof INITIAL_STATE, { testarg: 'success' }, IActExt> = {
    name: 'testbundle',
    actions: {
        testAction: async (a, b) => { },
    },
    init: store => {
        console.log('initializing store')
        console.log(store)
    },
    args: store => {
        console.log('passing in extra args')
        // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
        return {
            testarg: 'success',
        }
    },
    middleware: composition => store => next => action => {
        console.log('triggered middleware for action: ', action)
        if (isSpecificAction(action)) console.log(action.specificTag)
        else next(action)
    },
    reducer: (state = INITIAL_STATE, { type }) => {
        if (type === '@CORE/INIT')
            return {
                ...state,
                testState: state.testState + 1,
            }
        return state
    },
}

这是一个有效的 bundle,TSC 没有抛出错误,它的泛型已经定义,但是当你尝试将这个 bundle 作为之前提到的函数的参数时,会出现错误:

composeBundles(ExampleBundle)

错误信息:

Argument of type 'Bundle<{ testState: number; }, { testarg: "success"; }, IActExt>' is not assignable to parameter of type 'Bundle<any, object, object>'.
  Types of property 'middleware' are incompatible.
    Type 'MiddlewareGenerator<IActExt> | undefined' is not assignable to type 'MiddlewareGenerator<object> | undefined'.
      Type 'MiddlewareGenerator<IActExt>' is not assignable to type 'MiddlewareGenerator<object>'.
      Type 'object' is not assignable to type 'IActExt'.(2345)

这个错误让我感到困惑,因为如果你仔细观察,我试图将一个非常明确定义的 Bundle 传递给一个函数,该函数预期一个参数与之匹配的,尽管略有不同的形状,然而错误提示却说我在做相反的事情。它说 object 不能分配给 IActExt,而我从未分配过,我是不是应该做相反的事情?我在这里错过了什么?如果一个函数期望一个具有泛型值等于 objectBundle,而你传递了一个具有 T 泛型的 Bundle,其中 T extends object,按照我的逻辑和我所

英文:

I am trying to define types as collections of shaped types using generics but am either doing something wrong or TS cannot do it. I've tried a lot of things in the past week but most of it is "lost" due to trying other things over and over. I am not sure if its possible, but my guess is, it should be. I will try to simply this as much as possible, but it will be a longer post, sorry no TLDR for this one.

The amount of types needed to produce a minimal-viable-reproducible-example for this particular issue is like 200 lines of types-only code, most of which are irrelevant but because they all chain one into another, its hard to extract a simple example from them, thus I will explain the issue at hand and post a link to a typescript playground with the code in case someone needs to take a look.

For context, I am developing some form of Redux Extension, or Redux2.0 if you will.

I am trying to define a type for a "return value" of a function which takes in an "array" of Bundles and returns a result which is based on those bundles. What is a bundle you ask? Its sort of a "Redux Plugin", something like this:

interface Bundle&lt;
    S = any,
    Args extends object = object,
    ActionExt extends object = object
  &gt; {
    name: string
    reducer?: Reducer&lt;S&gt;
    selectors?: { [key: string]: Selector }
    reactors?: { [key: string]: Reactor }
    actions?: { [key: string]: AnyAction | ThunkAction | ActionExt | ?PossibleFutureProblem? }
    priority?: number
    init?: (store: Store) =&gt; void
    args?: ArgGenerator&lt;Args&gt;
    middleware?: MiddlewareGenerator&lt;ActionExt&gt;
    persist?: string[]
  }

So once the function processes multiples of these bundles, it is suppose to return a BundleComposition, that looks something like this:

interface BundleComposition {
  bundleNames: string[]
  reducers: { [key: string]: Reducer }
  selectors: { [key: string]: Selector }
  reactors: { [key: string]: Reactor }
  actions: { [key: string]: AnyAction }
  initMethods: Array&lt;(store: Store) =&gt; void&gt;
  args: Array&lt;{ [I in keyof any[]]: ArgGenerator&lt;any&gt; }[number]&gt;
  middleware: MiddlewareGenerator[]
  processed: Bundle[]
}

Problem I am having is, well twofold, so lets tackle them one by one

1. The Error Issue with generics/default values

When defining this function, we'd define it a function that takes in multiple Bundles and returns a BundleComposition, thus something like this would work:

type ComposeBundles = (...bundles: Bundle[]) =&gt; BundleComposition

Note that when defining this function, it is impossible to define what "shape" each of these bundles is, precisely, we know they must be a bundle, but Bundle type can, and most definitively should/will have it's type-arguments defined when creating it, however this function is used on multiple different bundles and thus we cannot define the shape of this "array" it accepts, because they are both unknown, and not the exact same shape.

Now, when we define a bundle, like such:

interface ICFG {
    tag: &#39;testconfig&#39;
}

interface IActExt {
    specificTag: number
}

const INITIAL_STATE = {
    testState: 0,
}

// a simple typeguard
const isSpecificAction = (action: any): action is IActExt =&gt; !!action.specificTag

const ExampleBundle: Bundle&lt;typeof INITIAL_STATE, { testarg: &#39;success&#39; }, IActExt&gt; = {
    name: &#39;testbundle&#39;,
    actions: {
        testAction: async (a, b) =&gt; { },
    },
    init: store =&gt; {
        console.log(&#39;initializing store&#39;)
        console.log(store)
    },
    args: store =&gt; {
        console.log(&#39;passing in extra args&#39;)
        // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
        return {
            testarg: &#39;success&#39;,
        }
    },
    middleware: composition =&gt; store =&gt; next =&gt; action =&gt; {
        console.log(&#39;triggered middleware for action: &#39;, action)
        if (isSpecificAction(action)) console.log(action.specificTag)
        else next(action)
    },
    reducer: (state = INITIAL_STATE, { type }) =&gt; {
        if (type === &#39;@CORE/INIT&#39;)
            return {
                ...state,
                testState: state.testState + 1,
            }
        return state
    },
}

This is a valid bundle, there is no errors thrown by the TSC, it's generics are well defined, but it is impossible to use this bundle as argument of the previously mentioned function, when you try to do the following, an error occurs:

composeBundles(ExampleBundle)

Error Message:

Argument of type &#39;Bundle&lt;{ testState: number; }, { testarg: &quot;success&quot;; }, IActExt&gt;&#39; is not assignable to parameter of type &#39;Bundle&lt;any, object, object&gt;&#39;.
  Types of property &#39;middleware&#39; are incompatible.
    Type &#39;MiddlewareGenerator&lt;IActExt&gt; | undefined&#39; is not assignable to type &#39;MiddlewareGenerator&lt;object&gt; | undefined&#39;.
      Type &#39;MiddlewareGenerator&lt;IActExt&gt;&#39; is not assignable to type &#39;MiddlewareGenerator&lt;object&gt;&#39;.
      Type &#39;object&#39; is not assignable to type &#39;IActExt&#39;.(2345)

And this error confuses me, because if you pay close attention, I am attempting to pass a VERY DEFINED BUNDLE into a function that expects a matching, albeit slightly different SHAPE as an argument, yet the error is saying I am doing the opposite. I read that object is not assignable to type IActExt where I've never assigned that, I did assign it the other way around no? What am I missing here? If a function expects a Bundle with a generic value equating object and you pass a Bundle with a generic of T where T extends object is that not suppose to work? The T is an extension of an object by my logic and everything I know about the whole SOLID/OOP shenanigans, this should work.

2. The whole "array" is not "really an array" problem

Truth be told, what we are dealing with in the function mentioned in issue 1 is not an "array", per say. It is as we can see a spread ("...") of multiple arguments, each of which is defined as a specific Bundle and the order of which is very well known because we are calling a function with arguments in a specific order, thus, we are dealing with a Tuple not an Array, but there is no way to define it as such because we don't know what the arguments will be once the function is invoked, nor how many will we have.

Essentially the issue is, we have defined the types:

type T&lt;G extends object = object&gt; = G // for simplicity, its obviously more then this

type myObjectWrapper = {
   subObjects: T[]
}

type myFunction = (...args: T[]): myObjectWrapper 

type T1 = T&lt;{a: string}&gt;
type T2 = T&lt;{b: string}&gt;

And then we implement the "myFunction" and expect to get the Result to be related to the input values of arguments, and the type-system should be aware of this, maybe not inside the body of the function (implementation), but certainly should be aware of it as a result of invocation.

const example: myFunction = (...args) =&gt; {
  // ...implementation stuff
  return { subObjects: args }
}

const a: T1 = { a: &#39;some string&#39; }
const b: T2 = { b: &#39;some other string&#39; }

const myResult = example(a, b) // doesn&#39;t work properly, type information is lost

So what is a proper pattern for defining these functions that accept an "array" of values, be it as an argument spread or an array if that makes it better somehow, where each value must be of some type T&lt;G&gt; but the types of G are different. This function returns an object wrapped around the values taken. How do we write this properly?

Because I find using a simple T[] doesn't work, yet I cannot specify a G because that could be anything that extends an object, which also forces me to define a "default" for the value G so I just default to object, but then I get errors from "issue 1" above.

答案1

得分: 1

我将翻译您提供的文本,以下是翻译好的部分:

我将首先讨论您在“2. 整个“数组”不是“真正的数组”问题”部分中的示例。

type T<G extends object = object> = G // 为简单起见,它显然不仅如此

type myObjectWrapper = {
   subObjects: T[]
}

myObjectWrapper 类型必须是一个泛型类型。否则,subObjects 将始终具有类型 T<object>[],无论 myFunction 的参数如何。

@Filly 是对的,您的泛型默认值太多了。但您没有正确理解推断的工作方式。

我看待这个方式是,没有默认值,TS 强制我在使用类型时定义泛型,它不会自动推断,这就是为什么我使用默认值,这样我可以编写不带 <G> 的类型,因为当我定义类型为 T<G> 时,每当我使用它时,它会要求我提供一个泛型。

type myObjectWrapper 中没有任何泛型可供推断,因为没有泛型。它始终是 { subObjects: T<object>[] }

利用类型推断的方法是定义强类型的函数,其中泛型类型参数(TG)在函数的各个参数和返回类型之间传递。推断发生在调用函数时,您不必在调用函数时指定任何泛型,因为它们可以从参数中推断出来。

T 是一个修改另一个类型 G 的高阶类型。因此,myFunction 需要知道它正在修改的是哪个 G。我认为您在链接的 Playground 中已经有点明白,您将 myFunction 设定为依赖于 G 的泛型。

type myFunction = <G extends T[]>(...args: G) => myObjectWrapper

但您存在一些基本矛盾,因为 G extends T[] 意味着 G 是一个 T 对象数组,但 type T<G extends object = object> = G 意味着 T 等于 G。这是不可能的。

因此,让我们明确一些假设:我将 myFunction 视为一个依赖于类型 G 的泛型。其参数是一个元素类型各不相同的数组,泛型类型参数 G 描述了这个元组的类型。该函数返回一个包装对象,其中 subObjects 包含每个输入对象的修改版本。

TypeScript 映射类型 可应用于元组和数组自 v3.1 以来。我们需要使用映射类型来描述将 ...args: G 转换为 subObjects: T<G>

在定义类型时,我没有使用默认值。每个泛型需要在实现中传递。

// 用于转换元组的映射类型。
type T<G> = {
    [K in keyof G]: G[K] & { added: string }
}

// 函数的返回类型。
interface myObjectWrapper<G> {
  subObjects: T<G>
}

// 该函数依赖于其输入元组类型 G。
type myFunction = <G extends object[]>(...args: G) => myObjectWrapper<G>

const example: myFunction = (...args) => {
  // 我们需要断言,因为 array.map 不理解它是一个元组。
  return { subObjects: args.map(g => ({ ...g, added: 'added' })) as any }
}

但在使用函数时,您不需要任何类型。您的类型 T1T2 可以删除。我们只需使用变量 ab 调用函数。

const a = { a: 'some string' }
const b = { b: 'some other string' }

const myResult = example(a, b)

这就是推断类型的作用。我们的函数是类型正确的,因此 myResult 变量得到了正确的推断类型:

const myResult: myObjectWrapper<[{ a: string; }, { b: string; }]>

这意味着每个 subObject 具有正确的、非常具体的类型。

// 没有错误。
console.log(myResult.subObjects[0].a)
console.log(myResult.subObjects[1].b)

// 预期中的错误,访问错误的数组元素上的类型。
console.log(myResult.subObjects[0].b)
console.log(myResult.subObjects[1].a)

我已经写了一本小说,还没有涉及到您的 Redux 类型。但希望我已经解释了一些基本原理。以下是一些建议:

  • 从您的类型中删除所有默认值,以强制自己传递已知的泛型。您需要在一个地方使用状态类型 S,以匹配另一个地方的 S
  • 在某些情况下,您可以使用单个泛型来引用一个非常复杂的类型,该类型本身包含许多泛型。这里没有丢失任何内容,因为新类型表示复杂类型的全部内容。例如:
const composeBundles = <B extends Bundle<any, any, any>[]>(...bundles: B): BundleComposition<B> => {
  • 在使用此类逻辑时,您可以定义类型以向后工作,以从复杂类型中提取内部类型。您已经在 StateFromReducersCollectionReducerFromReducerCollection 中这样做。
  • 尝试使用键控字典对象而不是元组。我发现这样更容易保持正确的类型并了解您正在访问的内容。
  • 查看 @reduxjs/toolkit 包的源代码和类型,因为它们已经处理了您在这里处理的许多问题。
英文:

I'm going to start by talking about the examples in your 2. The whole "array" is not "really an array" problem section.

type T&lt;G extends object = object&gt; = G // for simplicity, its obviously more then this

type myObjectWrapper = {
   subObjects: T[]
}

The myObjectWrapper type must be a generic type. Otherwise, subObjects will have type T&lt;object&gt;[] always, in all circumstances, regardless of the arguments of myFunction.

@Filly is right that you have far too many default values on your generics. But you're not properly understanding how inference works.

> The way I see it is, without default values, TS forces me to define the generic whenever I use a type, it doesn't infer it, this is why I make defaults so that I can write types like T WITHOUT the <G> next to it. Because when I define types as T<G> then whenever I use them, it asks me to also supply a generic.

There is nothing which can be inferred in type myObjectWrapper because there are no generics. It is always { subObjects: T&lt;object&gt;[] }.

The way to make use of type inference is to define strongly-typed functions, where the generic type parameters (T and G) are passed between the various arguments and return types of the function. The inference comes into play when you go to use the function. You will not have to specify any generics when calling the function because they can be inferred from the arguments.

T is a higher-order type that modifies another type G. Therefore myFunction needs to know what G it is modifying. I think understood that a little bit in your linked playground, where you made myFunction a generic which depends on G.

type myFunction = &lt;G extends T[]&gt;(...args: G) =&gt; myObjectWrapper

But you've got some fundamental contradictions, as G extends T[] means that G is an array of T objects but type T&lt;G extends object = object&gt; = G means that that T equals G. That's impossible.

So let's lay out some assumptions: I'll say that myFunction is a generic which depends on type G. Its argument is an array whose elements have varying types and the generic type parameter G describes the type of this tuple. The function returns a wrapped object where the subObjects is an array containing a modified version of each of your input objects.

TypeScript mapped types can applied to tuples and arrays since v3.1. We need to use a mapped type to describe the transformation of ...args: G to subObjects: T&lt;G&gt;.

When I define the types, I do not have default values anywhere. Every generic needs to be passed around in the implementation.

// Mapped type to transform the tuple.
type T&lt;G&gt; = {
    [K in keyof G]: G[K] &amp; { added: string }
}

// Return type of the function.
interface myObjectWrapper&lt;G&gt; {
  subObjects: T&lt;G&gt;
}

// The function depends on the type of its input tuple G.
type myFunction = &lt;G extends object[]&gt;(...args: G) =&gt; myObjectWrapper&lt;G&gt;

const example: myFunction = (...args) =&gt; {
  // We need an assertion because array.map doesn&#39;t understand that its a tuple.
  return { subObjects: args.map(g =&gt; ({ ...g, added: &#39;added&#39; })) as any }
}

But you don't need any types when you use the function. Your types T1 and T2 can go. All we do is call the function with variables a and b.

const a = { a: &#39;some string&#39; }
const b = { b: &#39;some other string&#39; }

const myResult = example(a, b)

This is where type inference comes into play. Our function is well-typed so the myResult variable gets the correct inferred type:

const myResult: myObjectWrapper&lt;[{ a: string; }, { b: string; }]&gt;

Which means that each subObject has the correct, very specific type.

// No errors.
console.log(myResult.subObjects[0].a)
console.log(myResult.subObjects[1].b)

// Errors, as expected, when accesing types on the wrong array element.
console.log(myResult.subObjects[0].b)
console.log(myResult.subObjects[1].a)

I've already written a novel and I haven't gotten into your Redux types. But hopefully I've explained some of the fundamentals. Here's some general advice:

  • Remove all default values from your types to force yourself to pass around the known generics. You need the state type S in one place to match the S in another place.
  • In some places you can use a single generic to refer to a vary complex type which itself contains a bunch of generics. Nothing is lost here, as the new type represents the entirety of the complex type. For example:
const composeBundles = &lt;B extends Bundle&lt;any, any, any&gt;[]&gt;(...bundles: B): BundleComposition&lt;B&gt; =&gt; {
  • When using this sort of logic, you can define types to work backwards to extract internal types from a complex type. You are already doing this with StateFromReducersCollection and ReducerFromReducerCollection.
  • Try using keyed dictionary objects instead of tuples. I find it a lot easier to keep the correct types and to know what you are accessing.
  • Look at the source code and the types for the @reduxjs/toolkit package as they've already dealt with a lot of the issues that you are dealing with here. Their createSlice function creates a "slice" object which is similar to your "bundle". It contains a strongly-typed reducer and a dictionary of strongly-typed action creator functions.
  • Read Lenz's article Do not create union types with Redux Action Types. It's most likely an antipattern. which explains why Redux Toolkit instead uses type guard functions to associate the payload type with an action.

huangapple
  • 本文由 发表于 2023年2月14日 00:04:40
  • 转载请务必保留本文链接:https://go.coder-hub.com/75438368.html
匿名

发表评论

匿名网友

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

确定