TypeScript 中循环类型之间的映射

Mapping Between Circular Types in TypeScript

这个问题是关于静态推断运行时类型的签名(见于 zod and io-ts 等库)。

可以在 this TS playground link.

看到下面的例子。

假设我们正在尝试为运行时使用的某些类型信息建模。我们可以声明以下 Type 枚举来让我们开始:

enum Type {
  Boolean = "Boolean",
  Int = "Int",
  List = "List",
  Union = "Union",
}

我们的运行时类型系统应该支持布尔值、整数、联合和列表。

基本类型如下所示:

interface Codec<T extends Type> {
  type: T;
}

布尔和整数类型像这样使用该基类型。

布尔值:

class BooleanCodec implements Codec<Type.Boolean> {
  type = Type.Boolean as const;
}

整数:

class IntCodec implements Codec<Type.Int> {
  type = Type.Int as const;
}

联合类型接受一个类型数组来联合:

class UnionCodec<C extends Codec<Type>> implements Codec<Type.Union> {
  type = Type.Union as const;
  constructor(public of: C[]) {}
}

列表类型接受其元素组成的类型:

class ListCodec<C extends Codec<Type>> implements Codec<Type.List> {
  type = Type.List as const;
  constructor(public of: C) {}
}

让我们构造一个布尔值或整数列表:

const listOfBooleanOrIntCodec = new ListCodec(
  new UnionCodec([
    new BooleanCodec(),
    new IntCodec(),
  ]),
);

计算结果为以下对象:

{
  type: Type.List,
  of: {
    type: Type.Union,
    of: [
      {
        type: Type.Boolean,
      },
      {
        type: Type.Int,
      },
    ]
  }
}

此编解码器将带有 ListCodec<UnionCodec<BooleanCodec | IntCodec>> 的签名。

我们甚至可能在给定的编解码器中看到循环,因此,映射类型签名变得很棘手。我们如何从上面得到 (boolean | number)[]?它是否考虑了编解码器的深度嵌套?

对于 BooleanCodecIntCodec,向后工作相当容易...但是 UnionCodecListCodec 解码需要递归。我尝试了以下方法:

type Decode<C extends Codec<Type>> =
  // if it's a list
  C extends ListCodec<Codec<Type>>
    ? // and we can infer what it's a list of
      C extends ListCodec<infer O>
      ? // and the elements are of type codec
        O extends Codec<Type>
        ? // recurse to get an array of the element(s') type
          Decode<O>[]
        : never
      : never
    : // if it's a union
    C extends UnionCodec<Codec<Type>>
    // and we can infer what it's a union of
    ? C extends UnionCodec<infer U>
      // and it's a union of codecs
      ? U extends Codec<Type>
        // recurse to return that type (which will be inferred as the union)
        ? Decode<U>
        : never
      : never
      // if it's a boolean codec
    : C extends BooleanCodec
    // return the boolean type
    ? boolean
    // if it's ant integer codec
    : C extends IntCodec
    // return the number type
    ? number
    : never;

遗憾的是 Type alias 'Decode' circularly references itselfType 'Decode' is not generic 错误。

我想知道是否有可能完成这种循环类型映射,以及如何使 Decode 这样的实用程序工作?任何帮助将不胜感激。谢谢!

我通常定义类型,然后从中派生通用编解码器,而不是显式构建编解码器。

例如:首先,用一些数据定义类型并对它们的关系(列表项和联合值)进行编码:

type Type = Integer | List<any> | Union<any>;
interface Integer {
  type: 'integer';
}
interface List<T extends Type> {
  type: 'list';
  item: T;
}
type UnionValues = Type[];
interface Union<T extends UnionValues> {
  type: 'union';
  values: T;
}

很高兴还提供了创建这些类型的助手:

const integer: Integer = { type: 'integer' };
const list = <T extends Type>(item: T): List<T> => ({
  type: 'list',
  item
});
const union = <T extends UnionValues>(...values: T): Union<T> => ({
  type: 'union',
  values
});

然后您可以编写递归类型映射函数。这会将 Type 映射到其对应的 JS 类型:

type Decode<T> =
  // terminal recursion: Integer is represented as a number
  T extends Integer ? number :
  // extract the Item from the list and construct an Array recursively
  T extends List<infer I> ? Decode<I>[] :
  // union is an array of types, so loop through and decode them
  T extends Union<infer U> ? {
    [i in Extract<keyof U, number>]: Decode<U[i]>;
  }[[Extract<keyof U, number>]] :
  never
  ;

将您的编解码器定义为从 Type => Value:

读取
interface Codec<T extends Type, V> {
  type: T;
  read(value: any): V;
}

编写一个将类型实例映射到其编解码器的函数:

function codec<T extends Type>(type: T): Codec<T, Decode<T>> {
  // todo
}

现在您可以安全地在类型系统和 JS 类型之间进行映射:

const i = codec(integer);
const number: number = i.read('1');

const l = codec(list(integer));
const numberArray: number[] = l.read('[1, 2]');

const u = codec(union(integer, list(integer)));
const numberOrArrayOfNumbers: number | number[] = u.read('1');

我试图重新创建您的示例,其中开发人员编写对其类型进行编码的编解码器。我认为这大致就是您要尝试做的事情。这有点复杂,因为您需要映射元组。

整数编解码器是整数 -> 数字的直接映射。

class IntegerCodec implements Codec<Integer, number> {
  public readonly type: Integer = integer;

  public read(value: any): number {
    return parseInt(value, 10);
  }
}

ListCodec 递归计算 List -> ItemValue[] 的映射

namespace Codec {
  // helper type function for grabbing the JS type from a Codec<any, any>
  export type GetValue<C extends Codec<any, any>> = C extends Codec<any, infer V> ? V : never;
}
// this is where we recurse and compute the Type and JSType from the provided Item codec
class ListCodec<Item extends Codec<any, any>> implements Codec<List<Item['type']>, Codec.GetValue<Item>[]> {
  public readonly type: List<Item['type']>;
  constructor(public readonly item: Item)  {
    this.type = list(item.type);
  }

  public read(value: any): Codec.GetValue<Item>[] {
    return value.map((v: any) => this.item.read(v));
  }
}

联合有点困难,因为我们需要映射一个编解码器元组来计算类型和值。

第一个实用程序:从编解码器的元组计算联合类型

type ComputeUnionType<V extends Codec<any, any>[]> = Union<Type[] & {
  [i in Extract<keyof V, number>]: V[i]['type']
}>;

第二个实用程序:从编解码器元组计算联合 JS 类型:

type ComputeUnionValue<V extends Codec<any, any>[]> = {
  [i in Extract<keyof V, number>]: Codec.GetValue<V[i]>;
}[Extract<keyof V, number>];

然后我们写一个 UnionCodec 递归计算一个 Union 的 Type 和 JS Type:

class UnionCodec<V extends Codec<any, any>[]> implements Codec<
  ComputeUnionType<V>,
  ComputeUnionValue<V>
> {
  public readonly type: ComputeUnionType<V>;

  constructor(public readonly codecs: V) {}
  public read(value: any): ComputeUnionValue<V> {
    throw new Error("Method not implemented.");
  }
}

现在您的示例类型检查:

const ic = new IntegerCodec();
const lc: ListCodec<IntegerCodec> = new ListCodec(new IntegerCodec());
const uc: UnionCodec<[ListCodec<IntegerCodec>, IntegerCodec]> = new UnionCodec([lc, ic]);

const listValue: number | number[] = uc.read('1');