在打字稿中以自引用方式键入对象

Posted

技术标签:

【中文标题】在打字稿中以自引用方式键入对象【英文标题】:Typing an object self-referentially in typescript 【发布时间】:2021-12-11 19:28:53 【问题描述】:

我一直在尝试键入用于构建索引存储的“配置对象”。我已将案例简化到最低限度,但希望动机仍然有意义。

我担心模型过于自我参照,无法在打字稿中表达,因为我一直在尝试定义类型时遇到死胡同。但是,我不知道下一个最好的方法是什么,它与 typescript 的表现力非常吻合。

特别是我找不到一个好的模式来定义类型约束以确保从某个索引消耗的函数对于该索引发出的行是正确的类型。

一个有效的索引配置如下面的chainedMap。如果我能解决我的打字问题,那么当函数参数之一与它“链接”的函数的返回值不匹配时,应该会生成编译器错误。

const chainedMap =  //configures a store for strings
  length: (value: string) => value.length, // defines a 'length' index populated by numbers
  threshold:  // defines a 'threshold' index, derived from length, populated by booleans
    length: (value: number) => value >= 10,
  ,
  serialise:  // defines a serialise index, derived from both length and threshold, populated by strings
    length: (value: number) => value.toString(),
    threshold: (value: boolean) => value.toString(),
  ,
 as const;

由于将索引链接在一起的意图,某些函数的参数类型与同一对象中其他函数的输出类型耦合。

有效的派生索引(如“阈值”或“序列化”)必须仅引用实际存在的命名索引,例如“长度”、“阈值”或“序列化”,并且必须定义使用数据类型的映射函数包含在该索引中,例如如果你从“长度”消费,你的函数应该接受数字,从“阈值”消费,你的函数应该接受布尔值。

尝试输入

chainedMap 中名为 FUNCTIONS 的***创建主索引。它们在添加到存储时使用行,并将行发送到相应命名的索引中。例如,隔离*** 'length' 索引,它可以像这样键入,对于采用字符串行的存储...

const lengthIndex: PrimaryMapping<string, number> = 
  length: (value: string) => value.length,
 as const;

chainedMap 配置中的***对象是索引派生的索引。这些对象包含命名函数,这些函数使用其相应命名索引中的行以在派生索引中生成行。例如,单独隔离***“阈值”属性(将行从长度索引转换为布尔索引)可以这样输入以消耗来自长度索引的行...

const lengthThresholdIndex: SecondaryMapping<typeof lengthIndex, boolean> = 
  threshold: 
    length: (value: number) => value >= 10,
  ,
 as const;

最后应该可以从派生索引中派生索引,从而可以构建任意链。从“序列化”索引中分离出一个映射,它可能是这样键入的......

const thresholdSerialisedIndex: SecondaryMapping<
  typeof lengthThresholdIndex,
  string
> = 
  serialise: 
    threshold: (value: boolean) => value.toString(),
  ,
 as const;

我得出了主索引和辅助索引的这些定义,以便能够以或多或少的类型安全方式构造配置对象,但与最初的简单配置对象相比,复杂性大大增加。重新创建简单配置所需的类型定义和组合如下所示...


interface PrimaryMapping<In, Out> 
  [indexCreated: string]: (value: In) => Out;


interface SecondaryMapping<
  Index extends PrimaryMapping<any, any> | SecondaryMapping<any, any>,
  Out
> 
  [indexCreated: string]: 
    [fromIndex: string]: (
      value: Index extends PrimaryMapping<any, infer In>
        ? In
        : Index extends SecondaryMapping<any, infer In>
        ? In
        : never
    ) => Out;
  ;


const lengthIndex: PrimaryMapping<string, number> = 
  length: (value: string) => value.length,
 as const;

const lengthThresholdIndex: SecondaryMapping<typeof lengthIndex, boolean> = 
  threshold: 
    length: (value: number) => value >= 10,
  ,
 as const;

const lengthSerialisedIndex: SecondaryMapping<typeof lengthIndex, string> = 
  serialise: 
    length: (value: number) => value.toString(),
  ,
 as const;

const thresholdSerialisedIndex: SecondaryMapping<
  typeof lengthThresholdIndex,
  string
> = 
  serialise: 
    threshold: (value: boolean) => value.toString(),
  ,
 as const;

const index = 
  ...lengthIndex,
  ...lengthThresholdIndex,
  serialise: 
    ...lengthSerialisedIndex.serialise,
    ...thresholdSerialisedIndex.serialise,
  ,
 as const;

但是,我正在努力寻找一种将这些组合起来的好方法,以从原始简洁配置对象的简单性中受益,但需要进行类型检查。为了让任何打字工作,我似乎必须在打字和声明中隔离这些链,这最终会造成可怕的混乱。

理想情况下,我会拥有例如一个 Index 类型,它会在下面的损坏示例中引发两个编译器错误

threshold: (value: number) =&gt; value.toString() 有一个 number 参数,但阈值索引返回 boolean 行。 foo: (value: boolean) =&gt; !value 引用了一个不作为chainedMap 的***属性存在的索引“foo”。
const chainedMap: Index<string> = 
  length: (value: string) => value.length,
  threshold: 
    length: (value: number) => value >= 10,
    foo: (value: boolean) => !value,
  ,
  serialise: 
    length: (value: number) => value.toString(),
    threshold: (value: number) => value.toString(),
  ,
 as const;

当我能够定义一个结合了 Primary 和 Secondary 的元素的单个 Recursive Mapped 类型时,我觉得我接近了......

interface Index<
  In,
  Out,
  I extends Index<any, In, any> | never = never
> 
  [indexName: string]:
    | ((value: In) => Out)
    | 
        [deriveFromName: string]: (
          value: I[typeof deriveFromName] extends (...args: any[]) => infer In
            ? In
            : I[typeof deriveFromName][keyof I[typeof deriveFromName]] extends (
                ...args: any[]
              ) => infer In
            ? In
            : never
        ) => Out;
      ;

...但它必须与它自己的类型 typeof chainedMap 的引用一起使用,这是非法的...

const chainedMap : Index <string, any, typeof chainedMap> = 
  length: (value: string) => value.length,
  threshold: 
    length: (value: number) => value >= 10,
  ,
  serialise: 
    length: (value: number) => value.toString(),
    threshold: (value: boolean) => value.toString(),
  ,
 as const;

是否可以有这样的自引用类型?

是否有另一种模式可以在我简单声明的配置对象中强制执行函数的逻辑完整性?

【问题讨论】:

感谢您修复代码围栏,@Nishant。我对自己在预览中错过了这一点感到失望。 你认为哪个函数是***的? 示例chainedMap 配置对象具有***属性(直接子级),它们是函数或包含函数的对象。在最终的实现中,像length 这样的***函数将被通知添加到存储中的每个(字符串)行,并将在长度索引中填充(数字)行。非***函数是***对象的属性。这些二级函数命名它们将用于创建派生索引的索引。他们不会收到存储的原始行的通知,只有行输出到命名索引。它们的类型应该对应。 我试图通过在原始 chainedMap 中添加一些注释行来澄清 【参考方案1】:

请不要将其视为完整的答案。这是一个在制品。只是想澄清一下。 考虑这个例子:


const chainedMap = 
    length: (value: string) => value.length,
    threshold: 
        length: (value: number) => value >= 10,
    ,
    serialise: 
        length: (value: number) => value.toString(),
        threshold: (value: boolean) => value.toString(),
    ,
 as const;

type Fn = (...args: any[]) => any

type TopLevel = Record<string, Fn>

const validation = <
    Keys extends string,
    Props extends Fn | Record<Keys, Fn>,
    Config extends Record<Keys, Props>
>(config: Validate<Config>) => config

type Validate<
    Original extends Record<
        string,
        Fn | Record<string, Fn>
    >,
    Nested = Original, Level = 0> =
    (Level extends 0
        ? 
            [Prop in keyof Nested]:
            Nested[Prop] extends Fn
            ? Nested[Prop]
            : Validate<Original, Nested[Prop], 1>
        
        : (keyof Nested extends keyof Original
            ? (Nested extends Record<string, Fn>
                ? 
                    [P in keyof Nested]: P extends keyof Original
                    ? (Original[P] extends (...args: any) => infer Return
                        ? (Parameters<Nested[P]>[0] extends Return
                            ? Nested[P]
                            : never)
                        : never)
                    : never
                
                : never)
            : never)
    )

type Result = Validate<typeof chainedMap>

validation(
    length: (value: string) => value.length,
    threshold: 
        length: (value: number) => value >= 10,
    ,
    serialise: 
        length: (value: number) => value.toString(), // ok
    ,
)

validation(
    length: (value: string) => value.length,
    threshold: 
        length: (value: number) => value >= 10,
    ,
    serialise: 
        length: (value: string) => value.toString(), // error, because top level [length] returns number
    ,
)

Playground

但是,我不确定treshhold。您没有将它作为***函数提供,而是在嵌套对象中使用它。可能我没明白什么。能否请您留下反馈意见?

附:代码乱七八糟,我会重构它,让它更干净

【讨论】:

感谢您的所有工作。今天下午笔记本电脑出现了问题,但我正在努力了解你所采取的方法的所有细微差别,看看我是否可以复制它。为了澄清,“阈值”索引是一个派生索引,它通过对长度索引中长度为 10 的行数进行阈值化来生成其布尔行。稍后,进一步的“序列化”索引通过从阈值索引 (以及长度索引中的行数)。索引的两步链 serialise=>threshold=>length - 演示从派生索引的派生索引。 我应该把它serialise=&gt;threshold=&gt;length当作一个函数组合吗?因为serialise 不是函数 在回答您的问题时,是的,阈值和序列化都是函数而非函数的映射。他们定义了要从中绘制的索引(地图中的每个名称)和要执行的转换(地图中的每个功能)。这与直接对传递给存储的项目而不是从某个命名索引的派生行进行操作的***索引形成对比。 感谢您分享您的专业知识和时间,@captain-yossarian。根据您的方法,我可能已经找到了解决方案。现在,在索引函数中引用无效的索引名称或使用不正确的参数会引发编译器错误,这完全符合预期。我想知道您是否可以看一下,看看是否有我遗漏的技巧可以让 Typescript 直接从 indexConfig 中“派生”validate(indexConfig) 的泛型类型。目前我必须提供配置和它自己的类型信息,例如validate&lt;typeof indexConfig, string&gt;(indexConfig)tsplay.dev/Nrvg1N 困扰我的第一件事是你必须使用显式泛型validate&lt;typeof indexConfig, string&gt;。 TS 能够在没有显式泛型的情况下推断文字对象。我还是不明白lengthserialise 的区别。为什么length 是***函数而treshhold 不是,因为它们是同类函数并且应该组合。您应该能够在没有任何显式泛型的情况下调用 validate,它应该可以工作【参考方案2】:

我现在有足够的方法让我进步。它不能从一个简单的配置对象推断出一切。该方法需要类型的少量“重复”。但是,以这种方式直言不讳可以被视为一种美德。

有效的声明现在看起来像...

const validConfig: Config<
  string,
   length: number; threshold: boolean; serialise: string 
> = 
  length: (value: string) => value.length,
  threshold: 
    length: (value: number) => value >= 10,
  ,
  serialise: 
    length: (value: number) => value.toString(),
    threshold: (value: boolean) => value.toString(),
  ,
 as const

Config 显式声明泛型参数,一个用于存储中接受的Stored 项目类型,另一个作为临时Sample 类型(从不实例化)来定义索引名称和有效条目的类型在那个索引中。

验证规则从这两种类型中投射出来以强制执行 对创建直接索引或派生索引的函数的约束,正确生成无效结构的编译错误。

// Function that populates an index by transforming items added to the store, or to other indexes' rows
type IndexFn<RowIn, RowOut> = (value: RowIn) => RowOut;

// A map that populates an index by transforming rows from one or more named indexes
type IndexMap<Sample, RowOut> = Partial<
  [consumedIndex in keyof Sample]: IndexFn<Sample[consumedIndex], RowOut>;
>;

// config combining direct indexes (functions that index items added to the store)
// and derived indexes (maps of functions that index other named indexes' rows)
type Config<Stored, Sample> = 
  [IndexName in keyof Sample]:
    | IndexFn<Stored, Sample[IndexName]> // a direct index
    | IndexMap<Sample, any>; // a derived index
;

产生编译错误的无效配置示例如下所示...

const invalidIndexName: Config<
  string,
   length: number; threshold: boolean; serialise: string 
> = 
  length: (value: string) => value.length,
  threshold: 
    length: (value: number) => value >= 10,
  ,
  serialise: 
    length: (value: number) => value.toString(),
    threshold: (value: boolean) => value.toString(),
    // there is no index foo
    foo: (value: number) => value.toString(), 
  ,
 as const;

const invalidDirectIndexArg: Config<
  string,
   length: number; negate: boolean 
> = 
  length: (value: string) => value.length,
  negate: 
    // length is a number not a boolean
    length: (value: boolean) => !value, 
  ,
 as const;

const invalidDerivedIndexArg: Config<
  string,
   length: number; threshold: boolean; serialise: string 
> = 
  length: (value: string) => value.length,
  threshold: 
    length: (value: number) => value >= 10,
  ,
  serialise: 
    // threshold is a boolean not a number
    threshold: (value: number) => value.toString(), 
  ,
 as const;

还有一点令人沮丧的是,我有正常工作的“提取”类型,可以直接从配置对象正确推断存储和样本类型,但我仍然找不到避免声明索引类型的方法。实用程序提取类型演示如下...

/** Utility type that extracts the stored type accepted by direct index functions */
type ExtractStored<C extends Config<any, any>> = 
  [IndexName in keyof C]: C[IndexName] extends IndexFn<infer RowIn, any>
    ? RowIn
    : never;
[keyof C];

/** Extracts the type emitted by a specific named index, whether direct or derived */
type ExtractRow<
  C extends Config<any, any>,
  IndexName extends keyof C
> = C[IndexName] extends IndexFn<any, infer DirectRowOut>
  ? DirectRowOut
  : C[IndexName] extends IndexMap<any, infer DerivedRowOut>
  ? DerivedRowOut
  : never;

/** Extracts an ephemeral Sample 'object' type mapping all named indexes to the index's row type. */
type ExtractSample<C extends Config<any, any>> = 
  [IndexName in keyof C]: ExtractRow<C, IndexName>;
;

/** Prove extraction of key aspects */

type TryStored = ExtractStored<typeof validConfig>; //correctly extracts string as the input type
type TrySample = ExtractSample<typeof validConfig>; //correctly creates type mapping index names to their types

type LengthType = ExtractRow<typeof validConfig, "length">; //correctly evaluates to number
type ThresholdType = ExtractRow<typeof validConfig, "threshold">; //correctly evaluates to boolean
type SerialiseType = ExtractRow<typeof validConfig, "serialise">; //correctly evaluates to string

将来如果有人可以利用这些提取的类型来允许例如一个简单的validate(indexConfig) 调用以引发编译器错误,而无需声明显式的通用 Store,内联示例将是一种改进。

以上所有例子都可以在https://tsplay.dev/wXk3QW的操场上进行实验

【讨论】:

感谢@captain-yossarian,他添加了这么多想法让我来到这里

以上是关于在打字稿中以自引用方式键入对象的主要内容,如果未能解决你的问题,请参考以下文章

如何在打字稿中键入枚举变量?

如何在打字稿中使用中间件键入 redux thunk

在打字稿中的基于 Vue 类的组件中键入 prop

打字稿中的通用对象类型

打字稿中的多个构造函数

如何在打字稿中组合对象属性?