Typescript第六章 类型进阶(类型之间的关系,全面性检查,对象类型进阶,函数类型进阶,条件类型等)

文章目录

  • 第六章 类型进阶
    • 6.1 类型之间的关系
      • 6.1.1 子类型和超类型
      • 6.1.2 型变
        • 结构和数组型变
        • 函数型变
      • 6.1.3 可赋值性
      • 6.1.4 类型拓宽
        • const类型
        • 多余属性检查
      • 6.1.5 细化
        • 辨别并集类型
    • 6.2 全面性检查
    • 6.3对象类型进阶
      • 6.3.1 对象类型的类型运算符
        • “键入”运算符
        • keyof运算符
      • 6.3.2 Record类型
      • 6.3.3 映射类型
        • 内置的映射类型
          • `Record`
          • `Partial`
          • `Required`
          • `Readonly`
          • `Pick`
          • 6.3.4 伴生对象模式
          • 6.4 函数类型进阶
            • 6.4.1 改善元组的类型推导
            • 6.4.2 用户定义的类型防护措施
          • 6.5 条件类型
            • 6.5.1 条件分配
            • 6.5.2 infer关键字
            • 6.5.3 内置的条件类型
              • Exclude
              • Extract
              • NonNullable
              • ReturnType
              • InstanceType
              • Exclude
          • 6.6 解决方法
            • 6.6.1 类型断言
            • 6.6.2 非空断言
            • 6.6.3 明确赋值断言
          • 6,7 模拟名义类型(隐含类型(opaque type))
          • 6.8 安全的扩展原型
          • 第六章 类型进阶

            Typescript一流的类型系统支持强大的类型层面编程特性,Typescript的类型系统不仅具有极强的表现力,易于使用个,而且可通过简介明了的方式声明类型约束和关系,并且多数时候能够自动为我们推导。

            本节首先讨论Typescript中的子类型,可赋值性,型变和类型拓展,加深你对前几章的认识。然后,深入说明Typescript基于控制流的类型检查特性,包括类型细化和全面性检查。

            接下来讨论类型层面的一些高级编程特性:“键入”和映射对象类型,使用条件类型,自定义类型防护措施,以及类型断言和明确赋值断言等。

            最后,介绍一些高级模式,尽量提升类型的安全性:伴生对象模式,改善元组的类型推导,模拟名义类型,以及安全扩展原型的方式。

            6.1 类型之间的关系

            首先讨论Typescript中的类型关系

            6.1.1 子类型和超类型

            3.1节讲过可赋值性。我们已经了解了Typescript提供的多数类型,现在可以深挖一些细节。

            子类型:给定两个类型A和B,假设B是A的子类型,那么在需要A的地方都可以放心使用B

            • Array是Object的子类型
            • Tuple是Array的子类型
            • 所有类型都是any的子类型
            • never是所有类型的子类型
            • 如果Bird类扩展自Animal类,那么Bird是Animal的子类型

            超类型正好和子类型相反

            6.1.2 型变

            多数时候,很容易判断A类型是不是B类型的子类型。例如:对number,string等类型来说,(number包含在并集类型number|string中,那么number必定是他的子类型)

            但是对**参数化类型(泛型)**和其他较为复杂的类型来说,情况不那么明晰。

            • 什么情况下Array是Array的子类型
            • 什么情况下结构A是结构B的子类型
            • 什么情况下函数(a:A)=>B是函数(c:C)=>D的子类型

            如果一个类型中包含其他类型(即带有类型参数的类型,如Array;带有字段的结构,如{a:number};或者函数,如(a:A)=>B),使用上述规则很难判断谁是子类型。

            为了便于理解,本书作者引入了一套句法,以便使用简介且准确的语言讨论类型。这套句法不是有效的Typescript代码,只是为了使用一套语言讨论类型。

            • A<:B指 “A类型是B类型的子类型,或者为同类类型”
            • A>:B指“A类型是B类型的超类型,或者为同类类型”

            结构和数组型变

            type ExistingUser = {
                id:number
                name:string
            }
            type NewUser = {
                name:string
            }
            // 假设我们写了一个这样的代码
            function deleteUser(user:{id?:number,name:string}){
                delete user.id
            }
            let existingUser:ExistingUser = {
                id:12345,
                name:"red润"
            }
            let newUser:NewUser = {
                name:"redrun"
            }
            deleteUser(existingUser)
            existingUser.id //类型(property) id: number (值为undefined)
            

            deleteUser接受一个对象,类型为{id?:number,name:string},我们传入的existingUser对象是{id:number,name:string}类型,注意,id属性的类型(number)是预期类型(number|undefined)的子类型。因此,{id:number,name:string}作为一个整体是{id?:number,name:string}的子类型,所以Typescript不会报错。

            这里有个安全问题:把ExistingUser类型的值传给deleteUser函数之后,Typescript不知道用户的id已经被删除,所以调用deleteUser(existingUser)把该属性删除之后再读取existingUser.id,Typescript认为existingUser.id是number类型。

            显然,在预期某个类型的超类型的地方使用该类型(子类型)是不安全的。由于破坏性更新(例如删除一个属性)在实际中很少见,所以Typescript放宽了要求,允许我们在预期某类型的超类型的地方使用那个类型。

            那么反过来,能不能子啊预期某类型的子类型的地方使用那个类型呢?

            添加一个旧用户,然后删除

            type ExistingUser = {
                id:number
                name:string
            }
            type NewUser = {
                name:string
            }
            type LegacyUser = {
                id?:number|string
                name:string
            }
            // 假设我们写了一个这样的代码
            function deleteUser(user:{id?:number,name:string}){
                delete user.id
            }
            let existingUser:ExistingUser = {
                id:12345,
                name:"red润"
            }
            let newUser:NewUser = {
                name:"redrun"
            }
            let legacyUser:LegacyUser = {
                id:4567,
                name:"oldrun"
            }
            deleteUser(existingUser)
            
            existingUser.id //(property) id: number
            
            deleteUser(legacyUser)// 报错
            

            我们传入的结构中有一个属性的类型是预期类型的超类型,Typescript报错了,这是因为id的类型是string|number|undefined,而deleteUser函数只处理了number|undefined的情况。

            Typescript的行为是这样的:对预期的结构,还可以使用属性的类型<:预期类型的结构,但是不能传入属性的类型是预期类型的超类型的结构。

            在类型上,我们说Typescript对结构(对象和类)的属性类型进行了 协变(covariant)

            也就是说,如果想保证A对象可赋值给B对象,那么A对象的每个成员都必须<:B对象的对应属性。

            其实,协变只是型变的四种方式之一:

            • 不变
              • 只能T
            • 协变
              • 可以是<:T
            • 逆变
              • 可以是>:T
            • 双变
              • 可以是<:T或>:T

            在Typescript中,每个复杂类型的成员都会进行协变,包括对象,类,数组,和函数的返回类型。不过有个例外:函数的参数类型进行逆变

            设计Typescript时,设计人员在易用性和安全行做出了权衡,允许型变对象的属性类型。例如

            // Obj的类型undefined|string|number
            interface Obj {
             name?:string|number
            }
            //实现Obj只能设置一个类型,不能多不能少!!!(这就是对象的属性类型不允许协变)
            let obj:Obj = {
             name:undefined
            }
            

            但是会导致类型系统用起来很繁琐,而且会禁止实际安全的操作(假如deleteUser没有删除id,完全可以传入预期类型的超类型,比如我们给id传入一个字符串类型,但是Typescript不支持)

            函数型变

            如果函数A的参数数量小于或等于函数B的参数数量,而且满足下面条件,那么函数A是函数B的子类型:

            1. 函数A的this类型未指定,或者>:函数B的this类型
            2. 函数A的各个参数的类型>:函数B的相应参数。
            3. 函数A的返回类型<:函数B的返回类型
            class Animal{}
            class Bird extends Animal{
                chirp(){}
            }
            class Crow extends Bird {
                caw(){}
            }
            
            function chirp(bird:Bird):Bird{
                bird.chirp()
                return bird
            }
            chirp(new Animal)// 报错 函数参数类型逆变
            chirp(new Bird)
            chirp(new Crow)
            

            函数返回类型的协变指一个函数是另一个函数的子类型,即一个函数的返回类<:另一个函数的返回类型。

            那么参数的类型呢

            function clone(f:(b:Bird)=>Bird):void{
                let parent = new Bird
                let babyBird = f(parent)
                babyBird.chirp()
            }
            function animaToBird(a:Animal):Bird{
                // 
                return new Bird
            }
            function crowTBird(c:Crow):Bird{
                return new Bird
            }
            clone(animaToBird)
            clone(crowTBird)//报错
            
            

            为了保证一个函数可赋值给另一个函数,该函数的参数类型(包括this)都要>:另一个函数相应参数的类型。

            函数不对参数和this的类型做型变。一个函数是另一个函数的子类型,必须保证该函数的参数和this的类型>:另一个函数相应参数的类型。

            我们不用记诵这些规则,代码编辑器能够很好的提示。

            6.1.3 可赋值性

            子类型和超类型关系是静态类型语言的核心概念,对理解可赋值性也十分重要(可赋值性指在判断需要B类型的地方可否使用A类型时才用的规则)。

            1. 如果A是B的子类型,那么需要B的地方也可以使用A
            2. A是any

            6.1.4 类型拓宽

            类型扩宽(type widening)是理解Typescript类型推导机制的关键。一般来说,Typescript在推导类型时会方宽要求,故意推导一个更宽泛的类型,而不限定为某个具体的类型。

            声明变量时如果允许以后修改变量的值(let,var),变量的类型将拓宽,从字面值放大到包含该字面量的基类型

            let a = "x" // string
            
            var c = ture // boolean
            
            // 不可变不一样
            const a = 'x' // 'x'
            const b = 3 // 3
            

            我们可以显示注解类型,防止类型被拓宽:

            let a:'x' = 'x' // 'x'
            // b重新为a拓宽
            const a = 'x'
            let b = a // string
            
            // 不让重新扩展
            const c:'x' = 'x'
            let d = c // 'x'
            

            初始化null或undefined的变量扩展为any:

            let a = null // any
            a = 3 // any
            

            const类型

            const类型可以防止类型拓宽。这个类型用作类型断言(6.6.1节):

            let a = {x:3}// {x:number}
            let c = {x:3} as const // {readonly x:3}
            

            const不仅能组织拓宽类型,还将递归把成员设为readonly,不管数据结构的嵌套层级有多深:

            let d = [1,{x:2}] // (number|{x:number})
            let e = [1,{x:2}] as const // readonly [1,readonly x:2]
            

            如果想让Typescript推导的类型尽量窄一些,请使用 as const

            多余属性检查

            Typescript检查一个对象是否可赋值给另一个对象类型时,也涉及到类型拓宽。

            type Options = {
                baseUrl:string
                cacheSize?:number
                tier?:'prod'|'dev'
            }
            class API {
            	constructor(private options:Options){}
            }
            new API({
                baseURL:"xxxx",
                tier:"prod"
            })
            // 如果有一个参数拼错了
            new API({
                baseURL:"xxx"
                tierr:"prod"
            })// 直接报错
            

            这是编写JavaScript代码经常遇到的问题,Typescript能够捕获这种问题。可是,对象类型的成员会做协变,Typescript是如何捕获这种问题的呢?

            过程是这样

            • 预期的类型{baseURL:string,cacheSize?:number,tier?:’prod’|‘dev’}/
            • 传入的类型{baseURL:string,tierr:string}
            • 传入的类型是预期类型的子类型,可是不知为何,Typescript知道要报告错误。

            Typescript之所以能够捕获这样的问题,是因为他会做多余属性检查,具体过程是:尝试把一个新鲜对象字面量类型(fresh object literal type)T赋值给另一个类型U时,如果T有不在U中的属性,Typescript将报错。

            新鲜对象字面量类型指的是Typescript从对象字面量中推导出来的类型。

            如果对象字面量有类型断言(6.6.1节),或者把对象字面量赋值给变量,那么新鲜字面量类型将扩宽为常规的对象类型,也就不能称其为新鲜对象字面量类型。

            6.1.5 细化

            Typescript才用的基于流的类型推导,这是一种符号执行,类型检查器子啊检查代码过程中利用流程语句(if,?,||和switch)和类型查询(如typeof,instanceof和in)细化类型。这是一个极其便利的特性,但是很少有语言支持。

            type Unit = 'cm'|'px'|'%'
            
            let units:Unit[] = ['cm','px','%']
            
            // 检查各个单位,如果没有匹配返回null
            function parseUnit(value:string):Unit|null{
                for(let i=0;i

            我们可以使用parseUnit函数解析用户传入的宽度值。width的值可能是一个数字(此时假定单位为像素),可能是一个带单位的字符串,也可能是null或undefined。

            下述代码对象类型做了多次细化

            type Unit = 'cm'|'px'|'%'
            
            let units:Unit[] = ['cm','px','%']
            
            // 检查各个单位,如果没有匹配返回null
            function parseUnit(value:string):Unit|null{
                for(let i=0;i
            1. 与null做不严格相等的等值检查便能在遇到JavaScript值null和undefined返回true。width的类型变成string|number
            2. typeof运算符查询值的类型。width的类型变成string
            3. if如果为真表示有单位,否则必为null
            4. 返回null。

            JavaScript有七个假值,null,undefined,NaN,0,-0,””和false。其他均为真值

            辨别并集类型

            Typescript能跟随你的脚步细化类型

            type UserTextEvent = {value:string}
            type UserMouseEvent = {value:[number,number]}
            
            type UserEvent = UserTextEvent|UserMouseEvent
            
            function handle(event:UserEvent){
                if(typeof event.value === 'string'){
                    event.value // string
                    // do something
                    return
                }
                event.value // [number,number]
            
            }
            

            Typescript知道,在if中event.value肯定是一个字符串(因为使用typeof做了检查)。这意味着,在if块后面,event.value肯定是[number,number]的元组类型,因为if后有return。

            如果事情变得复杂起来

            type UserTextEvent = {value:string,target:HTMLInputElement}
            type UserMouseEvent = {value:[number,number],target:HTMLElement}
            
            type UserEvent = UserTextEvent|UserMouseEvent
            
            function handle(event:UserEvent){
                if(typeof event.value === 'string'){
                    event.value // string
                    event.target // HTMLInputElement | HTMLElement!!!
                    // do something
                    return
                }
                event.value // [number,number]
                event.target //  HTMLInputElement | HTMLElement!!!
            }
            
            

            event.value的类型可以细化,但是event.value却不可以。handle函数的参数是UserEvent类型,可能传入UserTextEvent|UserMouseEvent类型的值。由于并集类型的成员有可能重复,因此Typescript需要一种更加可靠的方式,明确并集类型的具体情况。

            为此,要使用一个字面量类型标记并集类型的各种情况。一个好的标记要满足一下情况:

            • 在并集类型个组成部分的相同位置上。如果是对象类型的并集,使用相同的字段;如果是元组类型的并集,使用相同的索引。实际使用中,带标记的并集类型通常为对象类型。
            • 使用字面量类型(字符串,数字,布尔值等字面量)。可以混用不同的字面量类型,不过最好使用 同一种类型。通常,使用字符串字面量类型。
            • 不要使用泛型。标记不应该有任何泛型参数
            • 要互斥(即在并集类型中是独一无二的)

            更新代码

            type UserTextEvent = {type:'TextEvent',value:string,target:HTMLInputElement}
            type UserMouseEvent = {type:'MouseEvent',value:[number,number],target:HTMLElement}
            
            type UserEvent = UserTextEvent|UserMouseEvent
            
            function handle(event:UserEvent){
                if(event.type === 'TextEvent'){
                    event.value // string
                    event.target // HTMLInputElement
                    // do something
                    return
                }
                event.value // [number,number]
                event.target //  HTMLElement!!!
            }
            

            现在,根据标记字段(event.type)的值细化event,Typescript知道,在if分支中event必为UserTextEvent类型。由于标记是独一无二的,所以Typescript知道二者时互斥的。

            如果函数要处理并集类型的不同情况,应该使用标记。例如:在处理Flux动作,Redux规约器或React的useReducer时,其作用十分巨大。

            6.2 全面性检查

            全面性检查(也称穷尽性检查)是类型检查器所做的一项检查,为的是确保所有情况被覆盖了。这个概念源自模式匹配的语言,例如Haskell,OCaml等。

            tsconfig.json noImplictReturns将提示没有return

            Typescript在很多情况下,都会做全面性检查,如果发现缺少某种情况会提醒你,例如

            type Weekday = 'Mon'|'Tue'|'Wed'|'Thu'|'Fri'
            type Day = Weekday | 'Sat' | 'Sun'
            
            function getNextDay(w:Weekday):Day{// 报错
                switch(w){
                    case "Mon": return "Tue"
                }
            }
            

            很明显,这里遗漏了好多天。Typescript能捕获这个错误。

            错误提示我们可能遗留了某些情况,应该在最后加上一个兜底return语句,返回‘Sat’等值,也可能预示我们要调整函数的返回类型,改为Day|undefined。为每一天都加上case语句之后,这个错误将消失。由于我们注解了函数的返回类型,而没有分支能保证返回该类型的值,所以Typescript发出提醒。

            function isBig(n:number){// Not all code paths return a value.
                if(n>=100){
                    return true
                }
            
            }
            

            6.3对象类型进阶

            对象是JavaScript的核心,为了以安全的方式描述和处理对象,Typescript提供了一系列方式。

            6.3.1 对象类型的类型运算符

            还记得“并集类型和交集类型”一节介绍的|和&类型运算符。Typescript不只这两个。下面再介绍几个处理对象结构的类型运算符。

            “键入”运算符

            假设有个复杂的嵌套类型,描述从社交媒体API中得到的GraphQL API相应

            type APIResponse = {
                user:{
                    userId:string
                    friendList:{
                        count:number
                        friends:{
                            firstName:string
                            lastName:string
                        }[]
                    }
                }
            }
            function getAPIResponse():Promise{
                return Promise.resolve({
                    user:{
                        userId:"",
                        friendList:{
                            count:12,
                            friends:[{firstName:"red",lastName:"run"}]
                        }
                    }
                })
            }
            type FriendList = APIResponse['user']['friendList']
            
            function renderFriendList(friendList:FriendList){
                
            }
            

            任何结构(对象,类构造方法或类的实例)和数组都可以“键入”,例如。单个好友的类型可以这样声明:

            type Friend = FriendList['friends'][number]
            

            number是“键入”数组类型的方式,若是元组,使用0,1或其他数字字面量类型表示想“键入”的索引。

            “键入”的句法与JavaScript在对象中查找字段的句法类似,这是故意为之。既然能在对象中查找值,那么也能在结构中查找类型。但是要注意,通过“键入”查找属性的类时,只能使用括号表示法,不能使用点号表示法

            keyof运算符

            keyof运算符获取对象所有键的类型,合并为一个字符串字面量类型。

            type ResponseKeys = keyof APIResponse // user{}
            type UserKyes = keyof APIResponse['user'] //"friendList" | "userId"
            type FriendListKeys = keyof APIResponse['user']['friendList'] // "friends" | "count"
            

            把“键入”和keyof运算符结合起来,可以实现对类型安全的读值函数,读取对象中指定键的值:

            function get(o: O, k: K): O[K] {// 3.
                return o[k]
            }
            console.log(get({ name: "red润",age:17,ok:true }, "name"));// red润
            
            1. 函数的参数为一个对象O和一个键K
            2. keyof O是一个字符串字面量类型并集,表示o对象所有键。K类扩展这个并集(是该并集的子类型。
            3. O[K]的类型为在O中查找K得到的具体类型。接着2.说,如果K是‘name’那么在编译时get返回一个字符串;如果K是’age‘|’ok‘,那么get返回number|boolean.

            这两个类型运算符强大在于,可以准确安全的描述结构类型。。

            type ActiveityLog = {
                lastEvent: Date
                events: {
                    id: string
                    timestamp: Date
                    type: 'Read' | 'Write'
                }[]
            }
            
            let activeityLog: ActiveityLog = {
                lastEvent: new Date(),
                events: [
                    { id: "123", timestamp: new Date(), type: 'Read' },
                    { id: "456", timestamp: new Date(), type: 'Write' }
                ]
            }
            
            type Get = {
                (o: O, k1: K1): O[K1]
                (o: O, k1: K1, k2: K2): O[K1][K2]
                (o: O, k1: K1, k2: K2, k3: K3): O[K1][K2][K3]
            }
            
            let get: Get = (object: any, ...keys: string[]) => {
                let result = object
                keys.forEach(k => result = result[k])
                return result
            }
            
            console.log(get(activeityLog, 'events'));
            console.log(get(activeityLog, 'events', 0));
            console.log(get(activeityLog, 'events', 0, 'id'));
            // [
            //     { id: '123', timestamp: 2023-07 - 30T11: 55: 54.349Z, type: 'Read' },
            //     { id: '456', timestamp: 2023-07 - 30T11: 55: 54.349Z, type: 'Write' }
            // ]
            
            // { id: '123', timestamp: 2023-07 - 30T11: 55: 54.349Z, type: 'Read' }
            
            // 123
            

            6.3.2 Record类型

            Typescript内置的Record类型用于描述有映射关系的对象。

            type Obj = {
                name:string,
                age:number,
                gender:boolean
            }
            let a:keyof Obj = 'name'
            console.log(a);
            
            let b:Record = {
                name:'ddd',
                age:15,
                gender:"false"
            }
            console.log(b);// { name: 'ddd', age: 15, gender: 'false' }
            let c:keyof typeof b = "name"
            console.log(c);// name
            

            与常规的对象索引签名相比,Record提供了更多的便利:使用常规的索引签名可以约束对象中值的类型,不过键只能用string,number,或symbol类型;使用Record还可以约束对象的键为string和number的子类型。

            6.3.3 映射类型

            Typescript还提供了一种更强大的方式,即映射类型(mapped type),使用这种方式声明更安全。

            type Weekday = 'Mon'|'Tue'|'Wed'|'Thu'|'Fri'
            type Day = Weekday | 'Sat' | 'Sun'
            
            let nextDay:{[K in Weekday]:Day} = {
                Mon:"Tue",
                Tue:"Wed",
                Wed:"Thu",
                Thu:"Fri",
                Fri:"Sat",
            }
            

            映射类型使用独特的句法。与索引签名相同,一个对象最多有一个映射类型:

            type MyMappedType = {
                [key in UnionType]:ValueType
            }
            

            其实Typescript内置的Record类型也是使用映射类型实现的:

            type Record = {
                [P in K]:T
            }
            

            映射类型的功能比Record强大,在指定对象的键和值的类型以外,如果结合”键入“类型,还能约束特定键和值是什么类型。

            下面举几个例子说明使用映射类型可以做哪些事:

            type Account = {
                id:number
                isEmployee:boolean
                notes:string[]
            }
            // 所有字段都是可选的
            type OptionalAccount = {
                [K in keyof Account]?:Account[K]// 1.
            }
            // 所有字段都也为null
            type NullableAccount = {
                [K in keyof Account]:Account[K]|null // 2.
            }
            // 所有字段都是只读的
            type ReadonlyAccount = {
                readonly [K in keyof Account]:Account[K]// 3.
            }
            // 所有字段都是可写的(等同于Account)
            type Account2 = {
                -readonly[K in keyof ReadonlyAccount]:Account[K] // 4.
            }
            // 所有字段都是必须得(等同于Account)
            type Account3 = {
                [K in keyof OptionalAccount]-?:Account[K]// 5.
            }
            
            1. 新建对象类型OptionalAccount,与Account建立映射,在此过程中把各个字段标记为可选的。
            2. 新建对象类型NullableAccount,与Account建立映射,在此过程中为每个字段增加可选值null。
            3. 新建对象类型ReadonlyAccount,与Account建立映射,把各字段标记为只读(即可读不可写)
            4. 字段可以标记为可选(?)或只读(readonly),也可以把这个约束去掉。使用减号(-)运算符(一个特殊的运算符,只对映射类型可用)可以把?和readonly撤销,分别把字段还原为必须得和可写的。这里新建一个对象类型Account2,与ReadonlyAccount建立映射,使用减号(-)运算符把readonly修饰符去掉,最终得到的类型等同于Account.
            5. 新建对象类型Account3,与OptionalAccount建立映射,使用减号(-)运算符可以把可选(?)运算符去掉,最终得到的类型等同于Account.

            减号(-)运算符有个对应的(+)运算符。一般不直接使用加好运算符,因为它通常蕴含在其他运算符中。在映射类型中,readonly等效于+readonly,?等效于+?、+的存在只是为了确保整体协调。

            内置的映射类型

            前一节讨论的映射类型非常有用,Typescript内置了一些

            Record

            键的类型为Keys,值的类型为Values的对象。

            Partial

            把Object中的每个字段都标记为可选的

            Required

            把Object中的每个字段都标记为必须得

            Readonly

            把Object中的每个字段都标记为只读的

            Pick

            返回Object的子类型,只含指定的Keys

            6.3.4 伴生对象模式

            伴生对象源自Scala,目的是把同名的对象配对在一起。在Typescript中也有类似的模式,而且作用相似,即把类型和对象配对在一起,我们也称之为伴生对象模式

            伴生对象模式是下面这样的:

            type Currency = {
                unit: "EUR" | "GBP" | "JPY" | "USD",
                value: number
            }
            let Currency:any = {
                DEFAULT: "USD",
                from(value:number,unit = Currency.DEFAULT):Currency{
                    return {unit,value}
                }
            }
            console.log(Currency.DEFAULT);
            console.log(Currency.from(123));
            
            
            type Demo = {
                name:string
                test(name:string):boolean
            }
            let Demo = {
                name:"demo",
                test(name:Demo["name"]):boolean {
                    return false
                },
            }
            Demo.test('a')
            

            Typescript中的类型和值分别在不同的命名空间中。10.4节将深入说明。这意味着,在同一个作用域中,可以有同名(这里的Currency)的类型和值。伴生对象模式在彼此独立的命名空间中两次声明相同的名称,一个是类型,另一个是值

            这种模式有几个不错的性质。首先,可以把语义上归属为同一名称的类型和值放在一起。其次,使用方可以一次性导入二者。

            import {Currency} from './Currency'
            
            let amountDue:Currency ={// 1.
                unit:'JPY',
                value:23344
            }
            let otherAmountDue = Currency.from(330,"EUR")// 2.
            
            1. 使用的类型是Currency
            2. 使用的是值Currency

            如果一个类型和一个对象在语义上 有关联,就可以使用伴生对象模式,由对象提供操作类型的使用方法。

            6.4 函数类型进阶

            本节讲述函数类型常用的几种高级技术。

            6.4.1 改善元组的类型推导

            Typescript在推导元组的类型时会放宽要求,推导的结果尽量宽泛,不在乎元组的长度和各位置的类型。

            let a = [1,true] //(number|boolean)[]
            

            然而,有时候我们希望推导的结果更严格一些,把上例中的a试作固定长度的元组,而不是数组。当然,我们可以使用类型断言把元组转换成元组类型(6.6.1节),也可以使用as const断言(const类型)把元组标记为只读的,尽量收窄推导出的元组类型。

            我们可以利用剩余参数的类型方式,收窄推导结果,并标记为只读。

            function tuple(...ts:T//3.):T{//4.
                return ts//5.
            }
            let a = tuple(1,true) // [number, boolean]
            
            1. 声明tuple函数,用于构建元组类型(代替内置的[]语法)
            2. 声明一个类型参数T,他是unknown[]的子类型(表明T是任意类型的数组)
            3. tuple函数接受不定数量的参数ts.由于T描述的是剩余参数,因此Typescript导出的一个元组类型。
            4. tuple函数的返回类型与ts的推导结果相同
            5. 这个函数返回传入的参数。神奇之处全在类型中。

            6.4.2 用户定义的类型防护措施

            在某些情况下,比如函数的返回值,只说函数返回boolean还不够

            function isString(a:unknown):boolean{
                return typeof a === 'string'
            }
            let b = isString('a')// true
            let c = isString(false)// false
            console.log(b,c);
            

            看起来没问题,那么,在实际使用中,isString函数的效果如何

            function isString(a:unknown):boolean{
                return typeof a === 'string'
            }
            let b = isString('a')// true
            let c = isString(false)// false
            console.log(b,c);
            
            function parseInput(input:string|number){
                let formattedInput:string
                if(isString(input)){
                    formattedInput = input.toUpperCase()// 报错
                }
            }
            

            细化类型时可以使用的typeof运算符(6.1.5节),在这里怎么不起作用了。

            类型细化的能力是有限的,只能细化当前作用域中变量的类型,一旦离开这个作用域,类型细化能力不会随之转移到新作用域中。

            isString函数返回一个布尔值,但是我们要让类型检查器知道,当返回的布尔值是true时,表明我们传给isString函数的是一个字符串。为此,我们要使用用户定义的类型防护措施:

            function isString(a:unknown):a is string{
                return typeof a === 'string'
            }
            let b = isString('a')// true
            let c = isString(false)// false
            console.log(b,c);
            
            function parseInput(input:string|number){
                let formattedInput:string
                if(isString(input)){
                    formattedInput = input.toUpperCase()
                    console.log(formattedInput);
                }
            }
            parseInput("red润")
            

            类型防护措施是Typescript内置的特性,是typeof和instancecof细化类型的背后机制。可是有时我们需要自定义类型防护措施的能力,is运算符就起这个作用。如果函数细化了参数的类型,而且返回一个布尔值,我们可以使用用户定义的类型防护措施确保类型的细化能在作用域之间转移,在使用该函数的任何地方都起作用。

            用户定义的类型防护措施只限于一个参数,但是不限于简单的类型:

            type LegacyDialog = //
            type Dialog = //
            function isLegacyDialog(dialog:LegacyDialog|Dialog):dialog is LegacyDialog{
            //}
            

            用户定义的类型防护措施不太常用,不过使用这个特性可以简化代码,提升代码的可重用性。如果没有这个特性,要在行内使用typeof和instanceof类型防护措施,而构建isLegacyDialog和isString之类的函数做同样的检查可实现更好的封装,提升代码的可读性。

            6.5 条件类型

            在Typescript的众多特性中,条件类型算是最独特的。概括的说,条件类型表达的是,“声明一个依赖类型U和V的类型T,如果U<:V,把T赋值给A否则,把T赋值给B”

            type IsString = T extends string//1.
                ? true// 2.
                : false// 3.
            type A = IsString // true
            type B = IsString // false
            
            1. 声明一个函数,有一个泛型T。这个条件类型中的“条件”时T extends string,即“T是不是string的子类型?”
            2. 如果T是string的子类型,得到的类型是true
            3. 否则,得到的类型为false

            注意,这里的句法和值层面的三元表达式差不多,只是现在位于类型层面。和三元表达式相似的是,条件类型可以嵌套。

            条件类型不限于只能在类型别名中使用,可以使用类型的地方几乎都能使用条件类型。包括类型别名,接口,类,参数的类型,以及函数和方法的泛型默认类型。

            6.5.1 条件分配

            这个类型 等价于
            string extends T ? A:B string extends T ?A:B
            `(string number)extends T?A:B`
            `(string number
            type ToArray = T[]
            type A = ToArray // number[]
            type B = ToArray // (number|string)[]
            
            type ToArray2 = T extends unknown ? T[]:T[]
            type A = ToArray2 // number[]
            type B = ToArray2 // (number|string)[]
            

            这样做有什么作用呢?可以安全地表达一些常见的操作

            我们知道,Typescript内置了计算两个类型交集的&运算符,还内置了计算两个类型并集的|运算符。下面我们来够键Without,计算在T中而不在U中的类型:

            type Without = T extends U ? never : T
            
            type A = Without// number|string
            

            下面具体分析Typescript是如何实现的:

            1. 先分析输入的类型:type A = Without
            2. 把条件分配到并集中:type A = Without|Without|Without
            3. 带入Without定义,替换T和U:type A = (boolean extends boolean?never:boolean)|(number extends boolean?never:boolean)|(string extends boolean?never:boolean)
            4. 计算条件:type A = never|number|string
            5. 化简:type A = number|string

            如果条件类型没有分配性质,最终的结果将是never

            6.5.2 infer关键字

            条件类型的最后一个特性是可以在条件中声明泛型。在条件类型中声明泛型不使用这个句法,而使用infer关键字。

            下面声明一个条件类型ElementTpe,获取数组中元素的类型:

            // 获取数组中元素的类型: T[number]索引类型
            type ElementType = T extends unknown[] ? T[number] : T
            type A = ElementType<(number|string)[]> // string|number
            type B = ElementType //string
            

            使用infer关键字可以重写为:

            type ElementType2 = T extends (infer U)[] ? U : T
            type B = ElementType2 // number
            

            这里,ElementType和ElementType2是等价的。注意,infer子句声明了一个新的类型变量U,Typescript将根据传给ElementType2的T推导U的类。

            另外注意,U是在行内声明的,而没有和T一起在前声明。倘若在前面声明,结果如何?

            type ElementUgly = T extends U[] ? U : T;
            type C = ElementUgly// 需要两个参数
            

            更加复杂的例子

            type SecondArg = 
            F extends (a: any, b: infer B) => any ? B : never
            
            // 获取Array.slice的类型
            type F = typeof Array['prototype']['slice']
            type A = SecondArg // number|undefined
            

            可见,[].slice的第二个参数是number|undefined类型。而且在编译时便可知晓这一点。

            6.5.3 内置的条件类型

            利用条件类型可以在类型层面表达一些强大的操作,Typescript自带了一些全局可用的条件类型

            Exclude

            和前面的Without类型,计算在T中而不在U中的类型:

            type A = number|string
            type B = string
            type C = Exclude// number
            

            Extract

            计算T中可赋值给U的类型:

            type A = number|string
            type B = string
            type C = Extract // string
            

            NonNullable

            从T中排除null和undefined

            type A = {a?:number|null}
            type B = NonNullable// number
            

            ReturnType

            计算函数的返回类型(注意,不适用于泛型和重载的函数)

            type F = (a:number) = string
            type R = ReturnType // string
            

            InstanceType

            计算类构造方法的实例类型

            type A = {new():B}
            type B = {b:number}
            type I = InstanceType // {b:number}
            
            type A = {
                new():{b:number}
            }
            class B {
                b:number;
                constructor(){
                    this.b = 123
                }
            }
            let b:A = B
            let c:B = new B()
            console.log(c);
            

            Exclude

            和前面的Without类型,计算在T中而不在U中的类型:

            type A = number|string
            type B = string
            type C = Exclude// number
            

            6.6 解决方法

            有时候,我们没有足够的时间把所有类型都规划好,这时我们希望Typescript能相信我们,即便如此也是安全的。还有我们是从API中获取数据,而暂时还没有生成类型声明。

            Typescript提供了大量的特性,但是要少用。

            6.6.1 类型断言

            给定类型B,如果A<:B<:C,那么我们可以向类型检查器断定,B其实是A或C。注意:我们只能断定一个类型是自身的超类型或子类型,不能断定number是string,因为这两个类型毫无关系。

            Typescript为类型断言提供了两种句法:

            function formatInput(input:string){
                //
            }
            function getUserInput():string|number{
                return "" 
            }
            let input = getUserInput()
            
            formatInput(input as string)// 1.
            formatInput(input)//2.
            
            1. 使用断言(as)告诉Typescript,input是字符串,而不是string|number类型。如果想先测试formatInput函数,而且肯定getUserInput函数返回一个字符串,就可以这么做
            2. 类型断言的旧句法使用<>尖括号。这两种句法是相同的作用。

            优先使用as,<>尖括号和react中tsx语法冲突

            有时候,两个类型之间关系不明,无法断定具体类型,可以使用as any,

            显然,类型断言不安全,少用

            6.6.2 非空断言

            可为空的类型,即T|null或T|null|undefined类型,比较特殊,Typescript为此提供了专门的语法,用于断定类型为T,而不是null或undefined。

            type Dialog = {
                id?:string
            }
            function closeDialog(dialog:Dialog){
                if(!dialog.id){// 1.
                    return
                } 
                setTimeout(()=>{// 2.
                    removeFromDOM(
                        dialog,
                        document.getElementById(dialog.id) // 错误
                    )
                })
            }
            function removeFromDOM(dialog:Dialog,element:Element){
                element.parentNode.removeChild(element) // 4.
                delete dialog.id
            }
            
            1. 如果没有id就返回
            2. 在下一次时间循环时,删除对话框
            3. 身处一个箭头函数中,开始一个新作用域,Typescript不知道1.和2.之间的代码修饰了dialog,因此1.中所做的类型细化不起作用。
            4. 类似的,尽管我们知道对话框一定在dom中,而且绝对有父级dom,然而Typescript只知道element.parentNode的类型是Node|null

            Typescript提供特殊的句法!,确定不可能null|undefined

            type Dialog = {
                id?:string
            }
            function closeDialog(dialog:Dialog){
                if(!dialog.id){// 1.
                    return
                } 
                setTimeout(()=>{// 2.
                    removeFromDOM(
                        dialog,
                        document.getElementById(dialog.id!)! // 错误
                    )
                })
            }
            function removeFromDOM(dialog:Dialog,element:Element){
                element.parentNode!.removeChild(element) // 4.
                delete dialog.id
            }
            
            

            如果频繁使用非空断言,说明代码需要重构。

            type VisibleDialog = { id?: string }
            type DestroyDialog = {}
            type Dialog = VisibleDialog | DestroyDialog
            function closeDialog(dialog: Dialog) {
                if (!("id" in dialog)) {
                    return
                }
                setTimeout(() => {
                    removeFromDOM(
                        dialog,
                        document.querySelector("#" + dialog.id)!
                    )
                });
            }
            function removeFromDOM(dialog: VisibleDialog, element: Element) {
                if(element.parentElement){
                    element.parentElement.removeChild(element)
                    if( dialog.id ){
                        delete dialog.id 
                    }
                }
            }
            

            确认dialog有id属性之后(表明是VisibleDialog类型),即使在箭头函数中Typescript也知道dialog引用没有变化:箭头函数与外面的dialog相同,因此结果细化随之转移,不会像前例那样不起作用。

            6.6.3 明确赋值断言

            Typescrip为非空断言提供了专门的句法,用于检查有没有明确赋值。

            我们可以使用明确赋值断言告诉Typescript,使用!

            let userId!:string// 表明已经明确赋值
            fetchUser()
            userId.toUpperCase() // OK
            function fetchUser(){
                userId = globalCache.get('userId')
            }
            

            与类型断言和非空断言一样,如果经常使用明确赋值断言,可能表示你的代码有问题。

            6,7 模拟名义类型(隐含类型(opaque type))

            type CompanyId = string
            type OrderId = string
            type UserId = string
            type Id = CompanyId|OrderId|UserId
            
            function queryForUser(id:UserId){
                //
            }
            let id:CompanyId = "ge3633ghg"
            queryForUser(id);//ok!!!
            

            这时就体现名义类型的作用了。虽然Typescript不支持名义类型,但是我们可以使用类型烙印(type branding)技术模拟实现。使用类型烙印技术之前要稍微设置一下。

            首先,为各个名义类型合成类型烙印:

            type CompanyId = string & {readonly brand:unique symbol}
            type OrderId = string & {readonly brand:unique symbol}
            type UserId = string & {readonly brand:unique symbol}
            type Id = CompanyId|OrderId|UserId
            
            function CompanyId(id:string){
                return id as CompanyId
            }
            function OrderId(id:string){
                return id as OrderId
            }
            function UserId(id:string){
                return id as UserId
            }
            
            function queryForUser(id:UserId){
                //
            }
            let companyId = CompanyId("dgge")
            let orderId = OrderId("geg8g8eg")
            let userId = UserId("dgegeg")
            
            queryForUser(userId)//ok
            queryForUser(companyId)// error
            
            

            这种方式的优点是,降低了运行时的开销,没构建一个ID只需要调用一个函数,而且JavaScriptVM还有可能把函数放在行内。在运行时,一个ID就是一个字符串,烙印纯粹是一种编译时结构。

            同样,大多时候,没必要使用,烙印可以提高安全性。

            6.8 安全的扩展原型

            构建JavaScript时候,传统的观点是扩展内置的类型的原型不安全。

            虽然过去认为扩展原型不安全,但是有了Typescript提供的静态类型系统,可以放心扩展。

            function tuple<
                T extends unknown[] // 2.
            >(...ts:T):T{
                return ts
            }
            interface Array {//1.
                zip(list: U[]): [T, U][],
            }
            
            Array.prototype.zip = function (
                this: T[],//2.
                list: U[]
            ): [T,U][] {
                return this.map((v, k) => {
                    return tuple(v, list[k]) // 3.
                })
            };
            console.log([1,2,2].map(n=>n*2).zip(['a','b','c']));//[ [ 2, 'a' ], [ 4, 'b' ], [ 4, 'c' ] ]
            
            1. 首先让Typescript知道我们要为Array添加zip方法。我们利用接口合并特性(5.4.1节)增强全局接口Array,为这个全局定义的接口添加zip方法。这个文件没有显示导出或导入(意味着在脚本模式,10,2,3节),因此可以直接增强全局接口Array。我们声明一个接口,与现有的Array同名,Typescript负责将二者合并。如果文件在模块模式中(需要导入其他代码,便是这种情况),就要把全局扩展放在declare global类型声明中(11.1节),global是一个特殊的命名空间,包含所有全局定义的值(在模块模式中无需导入就能使用任何值,见第十章),可以增强模块模式文件中全局作用域内的名称。
            2. 然后在Array的原型上实现zip方法。这里使用this类型,以便让Typescript正确推导出调用zip方法的数组的类型T
            3. 由于Typescript推导出的映射函数的返回类型是(T|U)[](Typescript没那么智能,意识不到这个元组的0索引始终是T,1索引始终是U),所以我们使用tuple函数(6.4.1节)创建一个元组类型,而不使用类型断言。

            注意:我们声明的interface Array是对全局命名空间Array的增强,影响整个Typescript,即使没有导入文件,Typescript看来,[].zip方法也可用,为了增强Array.prototype,我们需要确保用到zip方法的文件都已经加载过了上面代码(zip.ts),这样才能让Array.prototype上的zip方法生效。

            编辑tsconfig.json,把zip.ts排除在项目之外,这样使用方必须先import导入:

            {
            	*exclude*:[
            		"./zip.ts"
            	]
            }
            

            现在可以使用zip方法了

            import "./zip"
            console.log([1,2,2].map(n=>n*2).zip(['a','b','c']));
            

            你可能感兴趣的:(Typescript学习指南,typescript,javascript,前端)