您當前的位置:首頁 > 文化

深入typescript型別系統:過載與子型別

作者:由 楊健 發表于 文化時間:2019-09-11

本文系列主要來源於筆者的一個提問

https://www。

zhihu。com/question/3411

79370

, 結合下面的回答,尤其是 @馬猴燒韭 的資料,結合平時碰到的TS

各種bug(feature),討論下typescript的型別系統。

哈哈先丟擲個問題,你能解釋下述程式碼a,b,c,d的結果嗎

type ReturnType2 any> = T extends (。。。args: any) => infer R ? R : ‘bomb’;

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裡是通不過型別檢查的

待續。。。

標簽: number  String  type  function  return