在打字稿中自引用地键入对象

Typing an object self-referentially in typescript

我一直在尝试键入一个'configuration object'用于构建索引存储。我已将案例简化到最低限度,但希望动机仍然有意义。

我担心该模型过于自指,无法用打字稿表达,因为我一直在尝试定义类型时遇到死胡同。但是,我不知道下一个最好的方法是什么,它确实与打字稿的表现力保持一致。

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

有效的索引配置如下 chainedMap 所示。如果我能解决我的打字问题,那么当函数参数之一与它来自 'chained' 的函数的 return 值不匹配时,应该会生成编译器错误。

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;

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

像'threshold'或'serialise'这样的有效派生索引必须仅引用实际存在的命名索引,例如'length'、'threshold'或'serialise',并且必须定义一个映射函数,该函数使用该索引中包含的数据类型,例如如果您从 'length' 消费,您的函数应该接受数字,并且从 'threshold' 消费,您的函数应该接受布尔值。

尝试输入

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

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

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

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

终于可以从派生索引中派生出索引,从而可以构建任意链。从 'serialise' 索引中分离出一个映射,它可能是这样输入的...

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 类型,它会在下面的损坏示例中引发两个编译器错误

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;

当我能够定义一个结合了主要元素和次要元素的单一递归映射类型时,我觉得我已经接近了......

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;

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

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

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


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。您没有将它作为顶级函数提供,而是在嵌套对象中使用它。可能我不明白什么。你能留下反馈吗?

P.S。代码比较乱,我会重构,让它更干净

我现在有一个适当的方法可以让我进步。它无法从一个简单的配置对象推断出所有内容。该方法需要较小的 'duplication' 类型。但是,以这种方式明确可以被视为一种美德。

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

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;

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

/** 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) 调用来引发编译器错误而不声明显式通用存储,内联示例将是一个改进。

以上所有示例都可以在 https://tsplay.dev/wXk3QW

的 playground 中进行实验