非结构化类型约束
创建类型映射以供约束使用。
引言
有时,类型之间存在不适合通过继承来建模的关系。这些类型可能仅在问题领域中相关,而不是结构上相关。一个常见的例子是 HTTP 请求和响应。它们本身没有相似的状态或行为,但它们在 HTTP 领域以及可能构建在 HTTP 之上的任何协议(如 IRCv3 或 Web API)中仍然相关。
总的来说,TypeLinker 允许您以一种干净、简洁的方式指定这些非结构化关系,从而使这些关系可以驻留在单一位置,而不是分散在您的项目中,以复杂的类型约束、方法重载、重复函数等形式存在。
我们可以利用这些信息来提供类型安全,而不会产生任何运行时开销,不需要任何基于类型的反射样板代码,并且是完全可擦除的,因此不会增加转译后 Javascript 的大小或复杂性。
哈哈哈哈!
特点
- 1:1、1:N 和 M:N 类型链接。
- 协变、逆变、双变头部搜索。
- 多头部搜索。
- 支持任意深度的链接和链接组(联合型和元组型)。
术语
- 头部 (Head):头部是一个非唯一值,与一个或多个尾部值相关联。头部可用于检索其尾部值。
- 尾部 (Tail):尾部是一个非唯一值,与一个或多个头部值相关联。尾部对其头部一无所知。
- 示例:x -> y -> z。在此示例中,y 既是 x 的尾部,又是 z 的头部。
- 这些术语基本上与它们在图论中 respective 定义的相反。它们在此处的使用方式更接近于它们在计算机科学中的口语化含义(想想链表),我认为这对软件工程师来说更容易理解。
- 链接 (Link):链接是从头部到尾部的 1:1 有向关联。
- S-关系:结构化关系(即继承风格/is-a 关系)。
- NS-关系:非结构化关系。这些通常是特定于域的,例如消息的请求和响应类型关系。
问题
快速回顾一下,Typescript 的类型系统本质上是结构化的。即使两个类型之间没有明确定义继承,如果一个类型的属性是另一个类型属性的子集(真子集或非真子集),那么它们就被认为是相关的。
//A and B are effectively the same type
class A { A: string; }
class B { A: string; }
//D extends C
class C { A: number; }
class D { B: string; A: number; }
class E { E: boolean; }
这就引出了一个问题:如果不存在唯一定义该关系的属性,我们如何建模非结构化关系(以下简称 NS-关系),例如 R
、S
和 T
?
好吧,一种方法是创建一个唯一的代理属性,该属性在类型中表示该关系。让我们看看我们如何用 R
和 S
来做到这一点。
class A
{
A: string;
R_e56bf097_aa36_4eb7_a0b8_905817b00554: true;
S_7ad50511_d9ac_4ca7_a7e0_f6bca8fdcdf0: true;
}
class B extends A {}
class C { A: number; S_7ad50511_d9ac_4ca7_a7e0_f6bca8fdcdf0: true; }
class D { B: string; A: number; R_e56bf097_aa36_4eb7_a0b8_905817b00554: true;}
撇开简洁性,这个解决方案有几个问题。
- 由于
S
关系,如果我们假设S
不会传递性地应用于D
,那么我们就不能再在C
和D
之间使用继承关系。我们可以显式地将D
中的S_7ad50511_d9ac_4ca7_a7e0_f6bca8fdcdf0
设置为false
,然后进行检查,但现在所有子类都需要了解这种特殊关系。这不是一个好主意。 - 没有办法在不弄乱类型结构和不累积我们要编写的样板代码来检查这些关系的情况下实现方向性。
- 没有办法在
C
和D
之间创建 NS-关系而不受严格限制。我们需要区分C
和D
之间真实的 NS-关系与D
从C
继承 NS-关系。这只能通过不允许其中一种情况来干净地实现。 - 我们正在降低类型的内聚性。它们现在有一个属性,该属性在类实例中没有任何用途,并且不代表静态类型的任何数据。我们将 NS-关系数据塞进了不属于它的地方。
- 与上一点相关的是,如果未来的工程师在重构过程中删除了这个属性,因为它看起来未使用,代码可能会以非常不明显的方式中断。例如,如果属性仅使用
extends
条件进行检查,则删除不会导致任何错误或警告。extends
将简单地解析到 false 分支而不是 true 分支。
这里的核心问题确实是一个概念问题。我们将 S-关系的含义扩展到包含 NS-关系。我们将本质上非结构化的东西强加到结构化范式中。我们有一个圆形的洞,而我们却试图把一个方形的钉子塞进去。
所以让我们更抽象地思考这个问题,找出我们实际上要解决的根源。从类型域的角度看类型,我们会得到类似这样的东西。
这些是两个类型域——头部(A、B、E)和尾部(D、C)之间所有关系。域可以代表任何使用类型的东西——泛型类型参数、变量等。我们可以从这张图和我们已经描述的问题中推断出一些一般性陈述。
- 这不是数学意义上的函数,因为域中的所有元素都可以映射到其他域中的多个元素。
- 域中的所有元素都映射到另一个域中的至少一个元素。
- 域元素之间的关系存在方向性。方向从头部域到尾部域。
现在让我们分析现有的类型映射技术,看看它们是否能达到上述属性。首先,我们来看一下基本函数。
type example1 = (x: A | B | E) => D | C;
type example2 = (x: A | B | E, y: D | C) => void;
这不起作用,因为它允许 E => D
和 (E, D) => void
,这分别是无效的关系,因为它们没有出现在前面的图形中。
type example1 = (x: A | B) => D | C;
type example1_2 = (x: E) => C;
type example2 = (x: A | B, y: D | C) => void;
type example2_2 = (x: E, y: C) => void;
这有效,但需要单独的函数签名。根据上下文,它可能会产生代码重复。只有当不同的 NS-关系也意味着不同的行为时,它才适用。那么使用泛型怎么样?
type example1 =
<
T extends A | B | E,
U extends (T extends A | B ? D | C : C)
>(x: T) => U;
type example2 =
<
T extends A | B | E,
U extends (T extends A | B ? D | C : C)
>(x: T, y: U) => void;
这有效!但它有一些相当大的缺点。最值得注意的是,随着新的 NS-关系被添加、删除或修改,约束需要在所有用例中仔细维护。U
约束内部的复杂性也可能开始失控。
我们现在确定了需要解决的两个主要问题。我们需要以一种易于维护和可扩展的方式存储 NS-关系,并且我们需要一个泛型约束解决方案来确保 NS-关系得到尊重。
重要提示:虽然函数在文章的大部分内容中被用作示例,但 NS-关系的一般用法适用于任何类型用例——函数、类,甚至变量——尽管它在某些情况下的有用性尚未得到充分探索。
解决方案
最初的解决方案是将 NS-关系数据存储在它所属的地方——而不是在类型内部。我们将创建一个由 NS-关系的标识符索引的表,其值为相关类型元组的元组。
type ns_relationships = {
'R': [[A,D], [B,D]],
'S': [[A,C], [B,C]],
'T': [[E,C]]
};
我们现在完成了 NS-关系存储,对吗?嗯,是的,也不全是。我们解决了《问题》中列出的所有先前存储问题,但在此过程中也引入了一些新问题。
- 我们现在不再只是一个属性,而是一个更复杂的对象,我们需要从中索引和提取信息,以确定给定类型是否存在 NS-关系。
- 这本身不算问题,但如果我们能将 NS-关系视为一个包罗万象的“关系集合”,就像类型系统处理正常 S-关系一样,那就更好了。
问题 #2 很容易解决。我们可以将对象折叠成所有 NS-关系的一个元组。
type ns_relationships = [[A,D], [B,D], [A,C], [B,C], [E,C]];
我们给 NS-关系起的标识符,如 R
或 S
,除非我们想显式包含或忽略某些 NS-关系,否则实际上不提供任何有用的功能。它主要是一个文档特性。因此,如果我们仍然能够任意地将 NS-关系分组以识别它们,同时又不失去将它们视为一个大的 NS-关系集合的能力,那就更好了。
type ns_relationships = [
[[A,D], [B,D]], //R
[[A,C], [B,C]], //S
[E,C] //T
];
这干净地实现了我们的目标,但我们现在必须支持元组的任意嵌套。所以我们解决了问题 #2,但用一个新的“复杂元组”问题替换了原来的问题 #1。然而,这个新的元组问题更容易解决;我们可以在使用时展平元组。
//Supporting type aliases not included for brevity.
//They do what it says on the tin:
// ToUnion: returns a union given a tuple or union.
// IsTuple: returns true given a tuple.
// IsUnion: returns true given a union.
type Flatten<Set> =
[Set] extends [never[]] ? //if the set is empty
never //then ignore it
: Flatten_Helper<ToUnion<Set>>; //else convert to union and call helper
type Flatten_Helper<Set> =
Set extends LinkBase ? //foreach union item, if this is a base-case set
Set //then we can't break it down anymore, return it
: true extends IsTuple<Set> | IsUnion<Set> ? //else if it's a union or tuple
Flatten<Set> //then flatten it
: Set; //else return it
这个过程类似于展平嵌套数组。有两部分需要特别提及:LinkBase
和 Flatten_Helper
的最终 Set
返回值。有人可能会争辩说,最终的 Set
返回值应该改为 never
,因为如果一个项到达该分支,它既不是基本情况、元组,也不是联合,因此甚至不应该在原始集合中。正是因为这个原因才保留它——不忽略格式不正确的集合,而是让它传播,以便开发人员可以识别错误。LinkBase
是一个简单的对象,带有一个属性,它为我们的基本情况元组提供了身份,以区别于用于分组的普通元组。这是为了防止 Flatten
将我们的 NS-关系元组展平成单个元素。
type LinkBase = { _isLink_2723ae78_ad67_11eb_8529_0242ac130003: true };
//Just a tuple with the LinkBase property mixed in.
type Link<T1, T2> = [T1, T2] & LinkBase;
type ns_relationships = [
[Link<A,D>, Link<B,D>], //R
[Link<A,C>, Link<B,C>], //S
Link<E,C> //T
];
type result = Flatten<ns_relationships>;
//result: Link<A,D> | Link<B,D> | Link<A,C> | Link<B,C> | Link<E,C>
//result without using LinkBase would be: A | D | B | C | E <- this is why LinkBase is needed.
处理联合类型更容易,因为它更容易检查元素是否存在,这就是为什么我们不将结果转换回元组。我们现在可以检查链接是否有效。
//Checks whether a given link exists in ns_relationships
type exists<T extends LinkBase> = T extends Flatten<ns_relationships> ? true : false;
解决问题 #1 的最后一步扩展了这个想法,以检查单个类型(而不是链接)是否在 NS-关系集合中有关联。由于我们已经能够将集合展平成链接集合,因此这相当直接。
type heads = Flatten<ns_relationships> extends Link<infer Head, any> ? Head : never;
type exists<T> = T extends heads ? true : false;
现在我们列出的所有问题都已解决。我们只需要定义最后一个有意义的操作——检索与头部关联的尾部。这将使我们能够确保 NS-关系得到尊重。默认情况下,我们将此作为不变关联。
type tailsOf<T extends heads> =
Flatten<ns_relationships> extends infer R ?
R extends Link<T, infer Tail> ? //infer Tails associated with T or its derivatives,
//also filters R down to these links which we use below.
Link<T, Tail> extends R ? //filter out Tails associated with derivatives
Tail //return Tails related to exactly T
: never
: never
: never;
现在我们有了一个可维护、可扩展的存储解决方案和一个泛型约束解决方案,用于确保 NS-关系得到尊重。这就是 TypeLinker 的基本思想,它扩展了这个简单的解决方案来处理边缘/错误情况,允许任何方差的关联,并且在使用上更灵活,例如允许多头部搜索。
以错误情况为例,never
作为链接的头部在概念上没有意义。never
是一种永远“不存在”的类型,因此通常表示错误。由于像 HeadsOf
和 TailsOf
这样的类型别名在接受参数后无法拒绝自身,并且我们的参数足够复杂,以至于我们必须使用类型别名对其进行处理才能生成有意义的约束,因此我们可以利用此属性将具有无效参数输入的类型别名解析为 never
。由于除了 never
之外,任何类型都无法扩展或赋值给 never
,这会导致拒绝发生在无效输入起源的上下文中。通过渲染该起源不可用,我们引起了它的注意,表明有问题。
type Map = unknown;
//HeadsOf can't reject all possible invalid inputs like unknown, never, [], void,
//null, undefined, etc with just a constraint on its type parameter.
declare const example: <T extends HeadsOf<Map>, U extends TailsOf<Map, T>>(x: T) => U;
//So instead we resolve it to never which makes example unusable until the invalid input is fixed.
example(1); //error; example is of type <never, never>(x: never)=>never
如何使用
现在我们理解了概念问题及其解决方案中的处理方式,让我们探讨如何使用我们的解决方案来解决实际问题。但首先,让我们快速回顾一下,并解决我们最初的问题陈述。
//S-relationships
class A { A: string; }
class B { A: string; }
class C { A: number; }
class D { B: string; A: number; }
class E { E: boolean; }
//NS-relationships
//R+S: A | B -> D | C
//T: E -> C
//M2N is a convenience alias that performs the cartesian product of the arguments.
type Map = [ Link_M2N<A | B, D | C>, Link<E, C> ];
type example1 =
<
T extends HeadsOf<Map>,
U extends TailsOf<Map, T>
>(x:T) => U;
type example2 =
<
T extends HeadsOf<Map>,
U extends TailsOf<Map, T>
>(x:T, y:U) => void;
declare const ex1: example1;
ex1(new A()); //A => D | C
ex1(new B()); //B => D | C
declare const ex2: example2;
ex2(new E()); //E => C
我们已成功地映射了关系,而没有重复代码、大量重载签名或允许无效的类型组合。上面使用的两个主要 TypeLinker 类型别名是:
HeadsOf<map>
:给定一个映射,返回映射中所有链接头部的联合。
TailsOf<map, head, variance?>
:给定一个映射和一个头部,返回具有给定头部链接的所有尾部的联合。可选参数方差允许指定应根据给定头部与映射中链接头部之间的方差来考虑哪些链接。默认是无/不变,但协变、逆变和双变也是支持的。
既然我们已经涵盖了基础知识,让我们继续讨论更具体的情景。
场景 #1
我们有一个进行 API 调用的应用程序。我们想要一个请求/响应解决方案,它:
- 确保使用正确的请求类型。
- 确保给定特定的请求类型,提供正确的响应类型验证器。
- 是这些调用的单一入口和出口点。
- 在添加其他请求和/或响应对象时不需要修改。
- 为 API 调用提供版本一致性。
共享解决方案数据
//version 1
class WhoIsRequest extends Request { user: string }
interface InvalidRequestResponse { responseType: 1 }
interface UserInfoResponse { responseType: 2 }
//version 2
class WhoAreRequest extends Request { users: string[] }
interface UsersInfoResponse { responseType: 3 }
//validators
function validateWhoIs(resp: any): resp is InvalidRequestResponse | UserInfoResponse {
return ('responseType' in resp && (resp.responseType == 1 || resp.responseType == 2));
}
function validateWhoAre(resp: any): resp is InvalidRequestResponse | UsersInfoResponse {
return ('responseType' in resp && (resp.responseType == 1 || resp.responseType == 3));
}
解决方案 #1
此解决方案使用传统的硬编码 NS-关系。
async function whoIsMessage(
req: WhoIsRequest,
isValidResponse: (resp:any) => resp is InvalidRequestResponse | UserInfoResponse
): InvalidRequestResponse | UserInfoResponse {
let response = await fetch(req as Request).then(resp => resp.json());
if (isValidResponse(response))
return response;
throw new Error("Invalid response.");
}
async function whoAreMessage(
req: WhoAreRequest,
isValidResponse: (resp:any) => resp is InvalidRequestResponse | UsersInfoResponse
): InvalidRequestResponse | UsersInfoResponse {
let response = await fetch(req as Request).then(resp => resp.json());
if (isValidResponse(response))
return response;
throw new Error("Invalid response.");
}
let success1 = await whoIsMessage(new WhoIsRequest('https://someapi'), validateWhoIs);
let success2 = await whoAreMessage(new WhoAreRequest('https://someapi'), validateWhoAre);
这种方法有几个缺点:
- 更改关系需要手动修改受影响的函数。
- 添加关系需要添加另一个函数。
- 每个函数中的代码是重复的。
这些问题与我们在“问题”部分前面确定的问题类型相同。
解决方案 #2
此解决方案使用 TypeLinker 通过泛型函数来表达 NS-关系。
type SomeApiMapping = {
v1: Link_O2N<WhoIsRequest, InvalidRequestResponse | UserInfoResponse>,
v2: Link_O2N<WhoAreRequest, InvalidRequestResponse | UsersInfoResponse>
};
async function message<
Map extends keyof SomeApiMapping = never,
T extends HeadsOf<SomeApiMapping[Map]> = HeadsOf<SomeApiMapping[Map]>,
U extends TailsOf<SomeApiMapping[Map], T> = TailsOf<SomeApiMapping[Map], T>
>(req: T, isValidResponse: (resp: any) => resp is U): Promise<U> {
let response = await fetch(req as Request).then(resp => resp.json());
if (isValidResponse(response))
return response;
throw new Error("Invalid response.");
}
let success1 = await message<'v1'>(new WhoIsRequest('https://someapi'), validateWhoIs);
//Error, incorrect request type
let error1 = await message<'v1'>(new WhoAreRequest('https://someapi'), validateWhoIs);
let success2 = await message<'v2'>(new WhoAreRequest('https://someapi'), validateWhoAre);
//Error, incorrect validator
let error2 = await message<'v2'>(new WhoAreRequest('https://someapi'), validateWhoIs);
一目了然,我们可以看出它没有解决方案 #1 的任何缺点。主要缺点是它更复杂。另一个小缺点是有人可以显式设置 T
和 U
,这可能会过度限制类型,例如将 U
设置为 UserInfoResponse
,然后会丢失 InvalidRequestResponse
类型。根据意图和具体情况,这可能是有益的,也可能不是。
如果需要,可以通过另一种方式来解决这个问题,即通过创建内部捕获的类型参数上下文来构造 message
,以确保 T
和 U
只能由 Map
定义。
function message_alt<Map extends keyof SomeApiMapping = never>() {
return (
<
T extends HeadsOf<SomeApiMapping[Map]>,
U extends TailsOf<SomeApiMapping[Map], T>
>() => {
return (
async (
req: T,
isValidResponse: (resp: any) => resp is U
): Promise<U> => {
let response = await fetch(req as Request).then(resp => resp.json());
if (isValidResponse(response))
return response;
throw new Error("Invalid response.");
}
);
}
)(); //Execute T and U's lambda, the key point that makes this all work.
};
let attempt3 = await message_alt<'v1'>()(new WhoIsRequest('https://someapi'), validateWhoIs);
//This approach also lets you create a re-usable message_alt pre-bound with Map, T, and U
//which can be useful in some circumstances.
let v2Message = message_alt<'v2'>();
let success4 = await v2Message(new WhoAreRequest('https://someapi'), validateWhoAre);
let success5 = await v2Message(new WhoAreRequest('https://backup.someapi'), validateWhoAre);
结论
对于这种情况,使用 TypeLinker 我们可以创建一个解决方案,没有重复的代码,更容易维护、扩展,并且更简洁地自文档化。我们唯一付出的代价是实现复杂性的增加。但使用方式保持不变。
场景 #2
我们想做一个通用的数据结构,使其具有领域意识,只允许合法的值。特别是,我们想要一个类似字典的数据结构,它只允许特定类型的键/值对。
解决方案 #1
//Valid type relationships:
// A->C, B->D, E->F
class DADictionary
{
private dictionary: any[] = [];
add(key: E, val: F): void;
add(key: B, val: D): void;
add(key: A, val: C): void;
add(key: unknown, val: unknown): void {
this.dictionary.push([key, val]);
};
get(key: E): F | undefined;
get(key: B): D | undefined;
get(key: A): C | undefined;
get(key: unknown): unknown | undefined {
return this.dictionary.find(tuple => tuple[0] === key)?.[1];
}
};
抛开实现,这种方法的主要缺点是您必须单独维护每个领域感知数据结构上的重载签名。对于静态域的 NS-关系,这可能不是问题,但如果关系被修改、添加或删除,则项目中的每个受影响的重载签名都需要反映这些更改。
解决方案 #2
//Valid type relationships:
// A->C, B->D, E->F
type DictMap = [ Link<A, C>, Link<B, D>, Link<E, F> ];
class DADictionary<DictMap>
{
private dictionary: any[] = [];
add<
Key extends HeadsOf<DictMap>,
Value extends TailsOf<DictMap, Key>
>(key: Key, val: Value): void {
this.dictionary.push([key, val]);
};
get<
Key extends HeadsOf<DictMap>,
Value extends TailsOf<DictMap, Key>
>(key: Key): Value | undefined {
return this.dictionary.find(tuple => tuple[0] === key)?.[1];
}
};
然而,这个解决方案可以完全通过 DictMap
类型别名来维护。如果这个映射用于其他领域感知数据结构,它们也将自动反映对映射所做的任何更改。
结论
这里的 TypeLinker 使我们能够避免在方法重载之间重复 NS-关系。它为这些关系提供了一个单一的参考点,这再次使它们更容易维护、扩展和记录。
附加信息
在处理泛型时,有时可能希望阻止类型推断发生。例如,如果您有一个复杂的映射,并希望用户明确他们想要的特定 NS-关系。可以使用 NoInfer
类型别名来完成此操作。
type NoInfer<T> = [T][T extends any ? 0 : never];
type Map = [ Link<string, number> ];
declare function example<
T extends HeadsOf<Map> = never,
U extends TailsOf<Map, T> = TailsOf<Map, T>
>(x: NoInfer<T>, y: U): void;
example('x' as string, 1); //Error due to NoInfer preventing T = never from being overwritten.
example<string, number>('x', 1); //Works correctly.
TailsOf
的可选方差参数还可以让您更好地建模与头部之间的 S-关系复合的 NS-关系。例如:
//Requests
class MessageRequest { requestId: number; }
class PingRequest extends MessageRequest { requestId: 1; }
class PrivmsgRequest extends MessageRequest { requestId: 2; user: string; msg: string; }
//Responses
class InvalidRequestResponse { responseId: 0; }
class PongResponse { responseId: 1; }
class MsgReceivedResponse { responseId: 2; }
type Map = [
Link<MessageRequest, InvalidRequestResponse>,
Link<PingRequest, PongResponse>,
Link<PrivmsgRequest, MsgReceivedResponse>
];
//PongResponse
let pingOnlyResponses: TailsOf<Map, PingRequest>;
//PongResponse | InvalidRequestResponse
let allPingResponses: TailsOf<Map, PingRequest, Variance.Contra>;
//MsgReceivedResponse | PongResponse | InvalidRequestResponse
let allResponses: TailsOf<Map, MessageRequest, Variance.Co>;
这使得建模这些情况变得容易,而无需为每个头部显式指定所有 NS-关系。在这里,通过 allPingResponses
,它允许 PingRequest
继承其祖先的 NS-关系,从而包含 InvalidRequestResponse
类型。
感谢阅读!
历史
21/5/5:首次发布。
21/5/5:修复了部分示例代码;更新了源代码以修复双变有时在静态分析期间未能完全解析的问题(请参阅 TypeLinker.ts 中的注释)。
21/9/2:文章完全重写。最初的文章过于侧重技术信息,而在解释链接器做什么或为什么它在概念上有用方面有所不足。技术类型系统信息已移至 类型系统功能,而本文档被重写,包含更多概念信息,并对技术描述进行了修改,希望能使其不那么令人困惑。
这还包括更新 TypeLinker 代码,以提供更一致的无效输入处理。
- 当
[]
、void
、null
、undefined
或never
用作映射时,HeadsOf
解析为never
。 - 当
[]
、void
、null
、undefined
或never
用作映射时,TailsOf
解析为never
。 never
不能作为链接的头部。[]
、void
、null
和undefined
是有效的链接元素。