前述文章讲到我们的 CRA(C reate R eact App)项目升级 TypeScript 到 v5 遇到了 TypeScript 版本冲突问题,但是没有讲到为何要进行此次升级,本文介绍原因以及详细介绍 TypeScript v5 新引入的一个语法。
ts-pattern Expected 0 arguments, but got 1
项目引入 ts-pattern 后,需要升级到 TypeScript v5,否则将报错:match doesnt work (Expected 0 arguments, but got 1)
:
issue 亦有记载 github.com/gvergnaud/t...
因为 ts-pattern 引入了一个新语法:"const 类型参数" (const type parameters)。
为何要引入 ts-pattern?
如果大家用过 Rust 或 Python 的
match
一定会被该库吸引,具体介绍可见我的这篇文章 TypeScript 系列:无需再写 if / else 了!引入 Rust / Python 模式匹配到 TS 中。
ts
export declare function match<const input, output = symbols.unset>(value: input): Match<input, output>;
// ^^^^^
const 类型参数即此处的 const input
。
const 类型参数
这是 TypeScript 5.0 引入的特性。作用:
- 它会尽可能保持字面量类型的精确性,而不是拓宽为更通用的类型
- 对于对象和数组,它会推断出只读类型
一句话介绍:const type parameters
让我们的类型能进一步收窄而无需在调用时每次手动增加 as const
。
为了了解新语法的用处,我们先看看普通泛型写法:
ts
declare function f<T>(x: T): T;
const r = f({ a: 1 }); // T 推断为 { a: number },不是 { a: 1 }
我们想进行严格推断,在 TypeScript v4 可以通过 as const
将类型从 number
收窄到字面量 1
:
ts
declare function f2<T>(x: T): T;
const r2 = f2({ a: 1 } as const); // T 推断为 { readonly a: 1 }
但每次调用都得加麻烦且易忘记 😩,v5 可以给入参增加 const
关键词,一劳永逸 💐:
ts
declare function f3<const T>(x: T): T;
// ^^^^^
const r3 = f3({ a: 1 }); // T 推断为 { readonly a: 1 }
大家可以在 playground 试试 www.typescriptlang.org/play/ 。
官方示例
我们再结合官方示例讲解下,官方示例是 v5.0 的,现在最新版本 v5.8.3 有些许变化也一并会讲到。
当 TypeScript 推断一个对象的类型时,通常会选用一种泛化 的类型。例如,在下面这段代码里,names
的推断类型是 string[]
:
ts
type HasNames = { names: readonly string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// 推断类型:string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
这样做的目的通常是为了后续还能对这个数组进行修改。
然而,根据 getNamesExactly
的具体实现及其使用场景,我们往往更希望得到更精确的类型。
在 5.0 之前,库作者通常只能建议调用者在某些地方手动加上 as const
来达到期望的推断:
ts
// 我们想要的类型:
// readonly ["Alice", "Bob", "Eve"]
// 实际得到的类型:
// string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
// 这样才行:
// readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"] } as const);
这种做法既繁琐又容易忘。在 TypeScript 5.0 中,你可以在类型参数声明前加 const
修饰符 ,让上述 as const
的推断成为默认行为:
ts
type HasNames = { names: readonly string[] }; // 我注:此处的 readonly 加或不加有差别下文会讲到
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
// ^^^^^
return arg.names;
}
// 推断类型:readonly ["Alice", "Bob", "Eve"]
// 注意:这里不再需要手动写 as const
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
注意,const
修饰符并不会拒绝可变值 ,也不要求约束必须是不可变类型 。
如果约束本身是可变类型,结果可能出乎意料。例如:
ts
declare function fnBad<const T extends string[]>(args: T): void;
// T 仍旧是 string[],因为 readonly ["a", "b", "c"] 不能赋值给 string[] - 我注:这里有误
fnBad(["a", "b", "c"]);
在这里,推断候选是 readonly ["a", "b", "c"]
,而只读数组不能赋给可变数组,于是推断回退到约束 string[]
,调用仍然成功。
我注:上述 fnBad 描述是 v5.0 的,最新版 TS 如果不加
readonly
并不会推断为string[]
而是["a", "b", "c"]
,二者的区别是后者只能添加字符串a
b
c
,但仍然是可变 的,因为虽然加了const
但是没有加readonly
。改一下返回值类型,就容易理解一些了
tsdeclare function fnBad<const T extends string[]>(args: T): T; // T 在 v5.0 是 string[],在 v5.8 是 ["a", "b", "c"],因为 readonly ["a", "b", "c"] 不能赋值给 string[] // v5.8 list 类型 ["a", "b", "c"] 非 readonly ["a", "b", "c"] 故可修改 const list = fnBad(["a", "b", "c"]); // 但只能 push "a" / "b" / "c",如果是 v5.0 可 push 任意字符串。 list.push('a')
正确的做法是把约束也设成 readonly string[]
(我注:这句话没错):
ts
declare function fnGood<const T extends readonly string[]>(args: T): void;
// T 推断为 readonly ["a", "b", "c"]
fnGood(["a", "b", "c"]);
同样要记住:const
修饰符只影响 在调用处直接写出 的对象、数组或基本类型字面量;对于"事先存好的变量"不会起作用:
ts
declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b", "c"];
// T 仍是 string[] ------ const 修饰符在此无效
fnGood(arr);
ts-pattern 结合 const type parameters
到这里我们就能理解为何 ts-pattern 需要依赖这个新特性。
ts
declare let input: "A" | "B";
return match(input)
.with("A", () => "It's an A!")
.with("B", () => "It's a B!")
.exhaustive();
如果不利用 const 类型参数,ts-pattern 的 with 函数参数就可以是任意字符串,有了 const 类型参数的"加持"才能做到精确推断 以及穷尽性检查。
更多阅读
公众号『
JavaScript与编程艺术
』。