TypeScript:验证映射的类型和const context

103 阅读3分钟

映射类型很好,因为它们允许对象结构的灵活性,这是JavaScript所熟知的。但它们对类型系统有一些关键的影响。以这个例子为例。

type Messages = 
  'CHANNEL_OPEN' | 'CHANNEL_CLOSE' | 'CHANNEL_FAIL' | 
  'MESSAGE_CHANNEL_OPEN' | 'MESSAGE_CHANNEL_CLOSE' |
  'MESSAGE_CHANNEL_FAIL'

type ChannelDefinition = {
  [key: string]: {
    open: Messages,
    close: Messages,
    fail: Messages
  }
}

这是一个通用的消息传递库,它需要一个 "通道定义",其中可以定义多个通道令牌。这个通道定义对象的键是用户希望它是什么。所以这是一个有效的通道定义。

const impl: ChannelDefinition = {
  test: {
    open: 'CHANNEL_OPEN',
    close: 'CHANNEL_CLOSE',
    fail: 'CHANNEL_FAIL'
  },
  message: {
    open: 'MESSAGE_CHANNEL_OPEN',
    close: 'MESSAGE_CHANNEL_CLOSE',
    fail: 'MESSAGE_CHANNEL_FAIL'
  }
} 

当我们想如此灵活地访问我们定义的键时,我们有一个问题。比方说,我们有一个函数,可以打开一个通道。我们传递整个通道定义对象,以及我们想要打开的通道。

declare function openChannel(
  def: ChannelDefinition,
  channel: keyof ChannelDefinition
)

那么,ChannelDefinition 的键是什么?嗯,是每个键:[key: string] 。因此,当我们指定一个特定的类型时,TypeScript将impl 作为这个特定的类型,忽略了实际的实现。契约被履行了。继续前进。这允许错误的键被传递。

// Passes, even though "massages" is no part of impl
openChannel(impl, 'massages') 

所以我们更关心的是实际的实现,而不是我们分配给常量的类型。这意味着我们必须摆脱ChannelDefinition 类型,并确保我们关心对象的实际类型。

首先,openChannel 函数应该接受任何属于ChannelDefinition 的子类型的对象,但要与具体的子类型一起工作。

- declare function openChannel(
-   def: ChannelDefinition,
-   channel: keyof ChannelDefinition
- )
+ declare function openChannel<T extends ChannelDefinition>(
+   def: T,
+   channel: keyof T
+ )

TypeScript现在在两个层面上工作。

  1. 检查T 是否实际扩展了ChannelDefinition 。如果是,我们就用类型来工作T
  2. 我们所有的函数参数都是用通用的T 。这也意味着我们通过keyof T ,获得了T真实键。

为了从中受益,我们必须去掉impl 的类型定义。明确的类型定义覆盖了所有的实际类型。从我们明确指定类型的那一刻起,TypeScript将其视为ChannelDefinition ,而不是实际的底层子类型。我们还必须设置const 上下文,所以我们可以将所有字符串转换为它们的单位类型(从而符合Messages )。

- const impl: ChannelDefinition = { ... };
+ const impl: { ... }  as const;

没有const 上下文,impl的推断类型是。

/// typeof impl 
{
  test: {
    open: string;
    close: string;
    fail: string;
  };
  message: {
    open: string;
    close: string;
    fail: string;
  };
}

有了const 上下文,impl 的实际类型就是现在。

/// typeof impl 
{
  test: {
    readonly open: "CHANNEL_OPEN";
    readonly close: "CHANNEL_CLOSE";
    readonly fail: "CHANNEL_FAIL";
  };
  message: {
    readonly open: "MESSAGE_CHANNEL_OPEN";
    readonly close: "MESSAGE_CHANNEL_CLOSE";
    readonly fail: "MESSAGE_CHANNEL_FAIL";
  };
}

const 上下文使我们能够满足 所制定的契约。现在, 正确的错误。ChannelDefinition openChannel

openChannel(impl, 'messages') // ✅ satisfies contract
openChannel(impl, 'massages') // 💥 bombs

你可能会在一个空间里需要使用具体的类型,满足ChannelDefinition 的契约,在一个函数之外。为此,我们可以使用Validate<T, U> 帮助类型来模仿同样的行为。

type Validate<T, U> = T extends U ? T : never; 

使用方法如下。

const correctImpl = {
  test: { 
    open: 'CHANNEL_OPEN', close: 'CHANNEL_CLOSE', fail: 'CHANNEL_FAIL'
  }
} as const;

const wrongImpl = {
  test: { 
    open: 'OPEN_CHANNEL', close: 'CHANNEL_CLOSE', fail: 'CHANNEL_FAIL' 
  }
} as const;


// ✅ returns typeof correctImpl
type ValidatedCorrect 
  = Validate<typeof correctImpl, ChannelDefinition>;

// 💥 returns never
type ValidatedWrong 
  = Validate<typeof wrongImpl, ChannelDefinition>;