深入typescript型別系統:過載與子型別
本文系列主要來源於筆者的一個提問
https://www。
zhihu。com/question/3411
79370
, 結合下面的回答,尤其是 @馬猴燒韭 的資料,結合平時碰到的TS
各種bug(feature),討論下typescript的型別系統。
哈哈先丟擲個問題,你能解釋下述程式碼a,b,c,d的結果嗎
type ReturnType2
type a = ReturnType2
type b = ReturnType2
type c = ReturnType2
type d = any extends () => void ? true : false;
在程式語言和型別論中,polymorphism指為不同的資料型別的實體提供統一的介面。 最常見的polymorphism包括
ad hoc: 為一組獨立的型別定義一個公共的介面,函式過載是常見的一種ad hoc polymorphism
subtyping: 如果S是T的subtype,那麼任何使用T型別物件的環境中,都可以安全的使用S型別的物件
parametric polymorphism: 即我們一般所說的泛型,宣告與定義函式、複合型別、變數時不指定其具體的型別,而把這部分型別作為引數使用,使得該定義對各種具體的型別都適用。
ad hoc polymorphism (過載)
js因為是動態型別,本身不需要支援過載,直接對引數進行型別判斷即可,但是ts為了保證型別安全,支援了函式簽名的型別過載,即多個overload signatures和一個implementation signatures
function add(x:string,y:string):string;
function add(x:number, y:number):number;
function add(x:string|number, y: number|string): number | string{
if(typeof x === ‘string’){
return x + ‘,’ + y;
}else {
return x。toFixed() + (y as number)。toFixed();
// 很不幸,ts暫時不支援對函式過載後續引數的narrowing操作,如這裡對x做了type narrowing但是對y沒有做narrowing,需要手動的y做type assert操作
見https://github。com/Microsoft/TypeScript/issues/22609
}
}
let x = add(1,2) // string
let y = add(2,3) // number
實現過載有幾個注意點
因為implementation signatures對外是不可見的,當我們實現過載時,通常需要定義兩個以上的overload signatures
function add(x:string,y:string):string;
function add(x:number, y:number):number;
function add(x:string|number|Function, y: number|string): number | string{
if(typeof x === ‘string’){
return x + ‘,’ + y;
}else {
return 1;
}
}
let x = add(1,2)
let z = add(() => {},1) // 報錯,implementation的signature不可見,即使implementation的signaure定義了function但是overload沒定義,所以type check error
implementation signature和 overload signature必須相容,否則會 type check error 如下 implementation 和 overload 不相容
function add(x:string,y:string):string;
function add(x:number, y:number):number; // 報錯,implementation沒有實現該overload signatue
function add(x:string, y: number|string): number | string{
if(typeof x === ‘string’){
return x + ‘,’ + y;
}else {
return 1;
}
}
let x = add(1,2)
overload signature的型別不會合並,只能resolve到一個
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
return x。length;
}
len(“”); // OK
len([0]); // OK
let t = Math。random() > 0。5 ? “hello” : [0]
len(t); // 這裡的t是string|number[]但是仍然check error
因此這裡就不要用過載了,直接用union type吧
function len(x: any[] | string) {
return x。length;
}
let t = Math。random() > 0。5 ? “hello” : [0]
len(t); // 沒問題了
因此多用union少用overload吧
control flow analysis && narrow
當使用union來實現函式過載時,ts可以透過control flow analysis,將union type narrowing到某個具體的型別,可以幫助我們保證我們的實現更加安全。
function padLeft(padding: number | string, input: string) {
return new Array(padding + 1)。join(“ ”) + input; // 報錯,string + 1 不合法
}
narrowing to rescue
function padLeft(padding: number | string, input: string) {
if (typeof padding === “number”) {
return new Array(padding + 1)。join(“ ”) + input;
}
return padding + input; // 此時padding的型別被narrowing為string了
}
side effect
如果control flow含有函式呼叫,那麼可能破壞control flow analysis,若1處雖然ts幫我們推斷了arg。x不為null,但是由於alert存在副作用執行時還是可能為null,導致執行時程式掛掉
function fn(arg: { x: string | null }) {
if (arg。x !== null) {
alert(arg);
console。log(arg。x。substr(3)); //1。 這裡的arg。x被設定為null了,所以這裡會導致runtime error
}
}
function alert(a:any) {
a。x = null;
}
flow的策略於此相反
// @flow
function otherMethod() { /* 。。。 */ }
function method(value: { prop?: string }) {
if (value。prop) {
otherMethod(); // flow認為otherMethod可能含有副作用,所以invlidate refinement
// $ExpectError
value。prop。charAt(0); // type check error
}
}
flow和ts在這裡採用了兩種策略,ts更加樂觀,預設函式沒有副作用,這可能導致runtime error,但是flow更加悲觀,預設存在副作用,導致typecheck error這可能導致很多無謂的判斷,一個傾向於completeness,一個傾向於sound
副作用標註
支援副作用標註,如果我們可以標註函式的副作用,就可以得到更合理的type check
function fn(arg: { x: string | null }) {
if (arg。x !== null) {
alert(arg);
console。log(arg。x。substr(3)); //1。 這裡的arg。x被設定為null了,所以這裡會導致runtime error
}
}
// 假想的語法,沒有支援
pure function alert(a:any) {
a。x = null;
}
更加詳細的討論見
https://
github。com/microsoft/Ty
peScript/issues/9998
https://
github。com/Microsoft/Ty
peScript/issues/7770
type guard
typescript透過 type guard來進行narrowing控制,ts內建瞭如下的type guard
in operator
interface Fish {
swim():void;
}
interface Bird {
fly():void;
}
function move(pet: Fish | Bird) {
if (“swim” in pet) {
return pet。swim();
}
return pet。fly();
}
typeof
function printAll(strs: string | string[] | null) {
if (typeof strs === “object”) {
for (const s of strs) {
console。log(s)
}
}
else if (typeof strs === “string”) {
console。log(strs)
}
else {
// do nothing
}
}
truth narrowing 除了
0
|
NaN
|
“”
|
0n
|
null
|
undefined
如下幾個value為falsy value,其他都是truth value,ts可以透過truth 判定來進行narrowing
function printAll(strs: string | string[] | null|undefined) {
if (strs) {
if(typeof strs === ‘object’){ // 這裡的strs被收斂到truthy value即 string | string[]
for (const s of strs) {
console。log(s)
}
}
}
// 由於“”為string但是為falsy,所以這裡的str為string | null | undefined
else if (typeof strs === “string”) {
console。log(strs)
}
}
equality narrowing
function foo(x: string | number, y: string | boolean) {
if (x === y) {
// 透過相等判定,x和y只能都為string
x。toUpperCase();
y。toLowerCase();
}
else {
console。log(x);
console。log(y);
}
}
instance narrowing
function logValue(x: Date | string) {
if (x instanceof Date) {
console。log(x。toUTCString());
}
else {
console。log(x。toUpperCase());
}
}
assignment narrowing
function foo() {
let x: string | number | boolean;
x = Math。random() < 0。5;
console。log(x);
if (Math。random() < 0。5) {
x = “hello”;
console。log(x。toUpperCase()); // narrow 為string
}
else {
x = 100;
console。log(x。toFixed()); // narrow為number
}
return x;
}
user-defined type guard
interface Foo {
foo: number;
common: string;
}
interface Bar {
bar: number;
common: string;
}
function isFoo(arg: any): arg is Foo {
return arg。foo !== undefined;
}
function doStuff(arg: Foo | Bar) {
if (isFoo(arg)) {
console。log(arg。foo); // OK
console。log(arg。bar); // Error!
}
else {
console。log(arg。foo); // Error!
console。log(arg。bar); // OK
}
}
doStuff({ foo: 123, common: ‘123’ });
doStuff({ bar: 123, common: ‘123’ });
algebraic data types && pattern match
上面提到的narrowing只適用於簡單的型別如string,boolean,number之類,通常我們可能需要處理更加複雜的型別如不同結構的物件,我們typescript可以透過discriminated union來實現對複雜物件的narrowing操作,discriminated union通常由如下幾部分組成
union: 是多個type的union
discriminant: union裡的每個type都必須要有一個公共的type property
type guard: 透過對公共的type property進行type check來實現narrowing
interface Circle {
kind: “circle”;
radius: number;
}
interface Square {
kind: “square”;
sideLength: number;
}
type Shape = Circle | Square;
//cut
function getArea(shape: Shape) {
switch (shape。kind) {
case “circle”:
return Math。PI * shape。radius ** 2;
case “square”:
return shape。sideLength ** 2;
}
}
exhaustive check
如我們此時新添加了一個shape型別 type Shape = Circle | Square | Rectangle, typescript會自動告訴我們getArea裡需要新增對新加型別的處理
interface Circle {
kind: “circle”;
radius: number;
}
interface Square {
kind: “square”;
sideLength: number;
}
interface Rectangle {
kind: ‘rectangle’,
width: number;
height: number;
}
type Shape = Circle | Square | Rectangle;
//cut
// 在開了noimplicitReturn:true情況下,會提示我們Not all code paths return a value。ts(7030)
function getArea(shape: Shape) {
switch (shape。kind) {
case “circle”:
return Math。PI * shape。radius ** 2;
case “square”:
return shape。sideLength ** 2;
}
}
當然我們也可以透過never直接進行檢測
function assertNever(x: never): never {
throw new Error(“Unexpected object: ” + x);
}
//cut
function getArea(shape: Shape) {
switch (shape。kind) {
case “circle”:
return Math。PI * shape。radius ** 2;
case “square”:
return shape。sideLength ** 2;
/*
case ‘rectangle’:
return shape。height * shape。width;
*/
default:
return assertNever(shape); // 這裡會type error,提示我們rectangle無法賦值給never
}
}
基於discriminant union我們實際上實現了ADT(algebraic data type), 在其他的functional programming language裡ADT和pattern match配合起來,有著驚人的表達能力,如haskell計算一個樹高
data Tree = Empty
| Leaf Int
| Node Tree Tree
depth :: Tree -> Int
depth Empty = 0
depth (Leaf n) = 1
depth (Node l r) = 1 + max (depth l) (depth r)
上面的演算法用ts表述如下,稍顯累贅
type Tree = TreeNode | Empty;
type Empty = {
kind: ‘empty’
}
type TreeNode = {
kind: ‘node’,
left: Tree,
right: Tree
}
const root: Tree = {
kind: ‘node’,
left: {
kind: ‘node’,
left: {
kind: ‘empty’
},
right: {
kind: ‘empty’
}
},
right: {
kind: ‘empty’
}
}
function depth(tree: Tree):number{
if(tree。kind === ‘empty’){
return 0;
}else{
return 1 + Math。max(depth(tree。left), depth(tree。right))
}
}
console。log(depth(root))
subtyping polymorphism(子型別)
soundness && completeness
在型別系統中
soundness
指type checker能夠reject所有的在執行時可能發生error的程式碼,這可能導致reject一些執行時不會發生error的程式碼
completeness
指type checker不reject正常的程式碼,只reject執行時可能發生error的程式碼,這可能導致有一些執行時可能產生error的程式碼沒被reject 理想的情況當然是type checker即是sound也是complete,但這是不可能實現的,只能在兩者中做tradeoff,事實上typescript即不sound也不completeness。事實上受限於javascript 的本身缺陷,基於javascript的type checker幾乎不可能做到sound,理由如下
A fully-sound type system built on top of existing JS syntax is simply a fool‘s errand; it cannot be done in a way that produces a usable programming language。 Even Flow doesn’t do this (people will claim that it‘s sound; it isn’t; they make trade-offs in this area as well)。 JS runtime behavior is extremely hostile toward producing a usable sound type system。 Getters can return a different value each time they‘re invoked。 The valueOf and toString functions are allowed to do literally anything they want; it’s possible that the expression x + y produces a nondeterministic type due to this。 The delete operator has side effects which are extremely difficult to describe with a type system。 Arrays can be sparse。 An object with a own property of type undefined behaves differently from an object with a prototype property of the same value。 What could even be done about code like const x = { [“to” + “String”]: 42 } + “oops”; (disallow all computed property names? disallow all primitive values? disallow operator + except when you‘ve。。。 done what, exactly)?
structural typing && nominal typing
大部分面向物件的語言都是nominal typing的,這意味著即使兩個型別的結構相同,也互不相容
class Foo {
method(input: string): number { 。。。 }
}
class Bar {
method(input: string): number { 。。。 }
}
let foo: Foo = new Bar(); // ERROR
這裡的Foo和Bar雖然結構相同由於不是同一個class name,所以並不相容 而對於structual typing,只進行結構比較,並不關心name,所以下述程式碼在ts裡並不會報錯
class Foo {
method(input: string): number { 。。。 }
}
class Bar {
method(input: string): number { 。。。 }
}
let foo: Foo = new Bar(); // Okay。
而flow的做法和typescript不同,其對class採用了nominal typing而對普通的物件和function採用了structural typing, typescript暫時並不支援nominal typing, 但可以透過其他方式進行模擬
https://
basarat。gitbooks。io/typ
escript/docs/tips/nominalTyping。html
, 事實上有一個pr正在個typescript新增nominal typing的支援
https://
github。com/microsoft/Ty
peScript/pull/33038
subtype && assignment
typescript裡的subtype和assignment在大部分情況下等價,我們後續討論不區分assignment和subtype
So far, we’ve used “compatible”, which is not a term defined in the language spec。 In TypeScript, there are two kinds of compatibility: subtype and assignment。 These differ only in that assignment extends subtype compatibility with rules to allow assignment to and from any, and to and from enum with corresponding numeric values。
事實上typescript大部分都是assignment compatibility檢測,即使是在extends的場景如 A extends B ? X : Y 這裡檢查的是A和B的assignment compatibility。
Different places in the language use one of the two compatibility mechanisms, depending on the situation。 For practical purposes, type compatibility is dictated by assignment compatibility, even in the cases of the implements and extends clauses。
structural assignability
由於ts暫時不支援nominal subtyping, 我們主要討論structural subtyping問題。
primitive type
對於primitive type,型別檢查很簡單
function isAssignableTo(source: Type, target: Type): boolean {
return source === target;
}
這裡的Type可以是null| string | number | Date | Regex 等基本型別,我們只需要直接比較兩者的型別是否相等即可,為了簡化討論 我們定義
=>
來表示 isAssignableTo,如下
target => source
這裡的target為subtype, source為 supertype 對於primitive type,=>可以等價於==,但是對於ts還支援其他複雜型別,此時=>和==就不等價了,如
structural types
union type ,intersect types
literal type , enum type
generic type
width subtyping
對於structual types, 比較兩個物件的是否型別相容,我們只需要檢查兩個物件的所有屬性的型別是否相同(這裡是===並不是 =>,對於物件屬性的=>的討論見depth subtyping)。
即 {a:T1,b:T2 } => { a:T1,b:T2}
如 target: {a:string,b:number} => source: { a: string, b: number}
另一個規則就是 target不能缺少source裡的member,如下就會產生錯誤
function distance(p: {x:number,y:number}){
return p。x*p。x + p。y*p。y
}
distance({x:1}) // error
這裡的target為{x:number} 而source 為{x:number,y:number} ,target裡缺少source的y會導致程式出現runtime error。
另一個規則也比較顯然 target裡在包含了source所有的屬性之外還可以包含source之外的屬性
function distance(p: {x:number,y:number}){
return p。x*p。x + p。y*p。y
}
distance({x:1,y:2,z:3}) // ok
上述的程式碼並不會產生runtime error,所以這條規則似乎也很顯然
exact type
但是有時候我們不希望我們的物件有額外的屬性,一個常見的場景是object。assign的使用
function assign(x:{name: string}, y:{age: number}){
return Object。assign({},x,y);
}
const x = {name: ’yj‘}
const y = {age: 20, name: ’override‘}
assign(x, y) //我們不希望x的name被y的name覆蓋掉,所以期望這裡ts報錯
flow對exact object type進行了支援,保證傳入的引數沒有額外屬性
/* @flow */
function assign(x:{name: string}, y:{|age: number|}){ // {||}表示exact object type
return Object。assign({},x,y);
}
const x = {name: ’yj‘}
const y = {age: 20, name: ’override‘}
assign(x, y) //flow 會報錯, Cannot call `assign` with `y` bound to `y` because property `name` is missing in object type [1] but exists in object literal [2]。
References:
但是ts尚未支援exact types,有相關pr
https://
github。com/microsoft/Ty
peScript/pull/28749
ts採取了另外一種方式來處理exact type的問題 ts對於object literal type進行了EPC(excess property check) 下述程式碼實際上是會報錯的
function assign(x:{name: string}, y:{age: number}){
return Object。assign({},x,y);
}
const x = {name: ’yj‘}
const y = {age: 20, name: ’override‘}
assign(x, {age: 20, name: ’override‘}) //我們不希望x的name被y的name覆蓋掉,所以期望這裡ts報錯
如果我們不希望進行ECF,則可以透過將object literal 賦值給變數來繞過ECF,因為ECF只適用於object literal
function assign(x:{name: string}, y:{age: number}){
return Object。assign({},x,y);
}
const x = {name: ’yj‘}
const y = {age: 20, name: ’override‘}
assign(x, y) // 繞過ecf檢查
depth subtyping
如果我們有如下兩個class
class Person { name: string }
class Employee extends Person { department: string }
可知Employee是Person的subtype,即Employee => Person,我們可以將Employee賦值給Person的
class Person { name: string }
class Employee extends Person { department: string }
var employee: Employee = new Employee;
var person: Person = employee; // OK
但是能否將包含Employee屬性的物件賦值給包含Person屬性的物件呢,考慮下述case
class Person { name: string }
class Employee extends Person { department: string }
var employee: { who: Employee } = { who: new Employee };
var person: { who: Person } = employee;
上述程式碼,在flow裡是出錯的,在typescript是透過的,顯然在這裡ts和flow又採取了不同的策略,實際上flow策略更加安全,如如下程式碼 雖然在ts里正常透過,但是實際上會導致runtime error
class Person {
name: string
constructor() {
this。name = ’person‘
}
}
class Employee extends Person {
constructor(public department: string) {
super();
}
}
var employee: { who: Employee } = { who: new Employee(’department‘) };
var person: { who: Person } = employee;
person。who = new Person
employee。who。department。toUpperCase(); // runtime error
這裡出錯的根源在於person和employee是mutable的,如果他們是immutable的,那麼person。who = new Person 這步操作就會被禁止,也就不會導致runtime error,結論就是在mutation情況下,depth subtyping 會導致unsound
事實上typescript不僅僅是object 是covaraint的,陣列也是covariant的,同樣會導致runtime error
class Person {
name: string
constructor() {
this。name = ’person‘
}
}
class Employee extends Person {
constructor(public department: string) {
super();
}
}
let person_list: Person[] = [];
let employee_list: Employee[] = [];
person_list = employee_list;
person_list[0] = new Person;
employee_list[0]。department。toUpperCase();
而flow的陣列是invariant則更為安全
type variance
簡單介紹下各種type variance
我們有三個類
class Noun {x:string}
class City extends Noun {y:string}
class SanFrancisco extends City {z:string}
// 由於ts的class是structual typing,所以
class A{}
class B extends A{} 中A =>B && B=>A ,A和B是等價的,所以需要額外加成員來區分,
在flow裡就不用了
可以得知 SanFrancisco => City => Noun City是Noun的subtype, Noun是City的supertype ts不支援type variance標註,我們這裡使用flow來說明
invariance
function method(value: InvariantOf
method(new Noun()); // error。。。
method(new City()); // okay
method(new SanFrancisco()); // error。。。
invariance 不接受supertype和subtypes
covariance
function method(value: CovariantOf
method(new Noun()); // error。。。
method(new City()); // okay
method(new SanFrancisco()); // okay
covariance接受subtype不接受supertype
congtravariance
function method(value: ContravariantOf
method(new Noun()); // okay
method(new City()); // okay
method(new SanFrancisco()); // error。。。
contravariance 接受supertype但不接受subtype
bivarance
function method(value: BivariantOf
method(new Noun()); // okay
method(new City()); // okay
method(new SanFrancisco()); // okay
bivarance即接受supertype也接受subtype 事實上flow對於物件屬性和陣列都是invariant的,而ts對於物件和陣列都是coviant的
function subtyping
ts2。6之前對於引數是bivariant的,引數協變其實會產生問題,逆變則沒有問題
function dist1(p:{x:number,y:number}){
return p。x+p。y
}
function dist2(p:{x:number,y:number,z:number}){
return p。x + p。y + p。z
}
let f: typeof dist1 = dist2;
let g: typeof dist2 = dist1;
console。log(g({x:1,y:2,z:3})// ok
console。log(f({x:1,y:2})) // 結果為NaN,因為 p。z為undefined
因此ts在2。6之後,在開啟strictFunctionTypes=true的情況下,函式引數變為了contravariant了則更加安全了。
這個規則也可以推理不同引數的函式的subtying情況, 首先考察tuple的subtyping問題
let tuple = [1,2] as [number,number];
let tuple2 = [1,2,3] as [number,number,number]
let t1: typeof tuple = tuple2; // ok
let t2: typeof tuple2 = tuple; // error because t2[2]。toFixed() will cause runtime error
可知 t2 => t1 即tuple長度大的是長度小的子型別 由於ts的多參函式實際可以視為tuple,且function是逆變的,因此可以推得引數少的可以賦值給引數多的
function add1(x:number,y:number){
return x+y;
}
function add2(x:number,y:number,z:number){
return x+y+z
}
let f1: typeof add2 = add1; // ok,because [number,number,number] => [number,number] and function contravariant
let f2: typeof add1 = add2; // error
我們再考慮返回值
function f(): {x:number,y:number}{
return {x:1,y:2}
}
function g(): {x:number,y:number,z:number}{
return {x:1,y:2,z:3}
}
let a: typeof f = g;
let b: typeof g = f;
a()。x // ok
b()。z // error
我們可以看到對於函式返回值應該設計為協變,這裡flow和ts都是採用協變,不同的是ts在這裡又做了個特殊的處理,考慮如下程式碼
let t = {x:1,y:2,z:3};
function f(): void{
}
function g(): string{
return ’a‘
}
let a: typeof f = g; // should error but ok
const res = a();
這裡雖然void 既不是string的subtype又不是supertype,但是這裡仍然能夠透過檢查,這裡ts故意為之見https://github。com/Microsoft/TypeScript/wiki/FAQ#why-are-functions-returning-non-void-assignable-to-function-returning-void, 但在flow裡是通不過型別檢查的
待續。。。