TypeScript。高级类型

图片



你好居住者!我们向印刷厂提交了另一种新颖性

专业TypeScript。可扩展JavaScript应用程序的开发”。在这本书中,已经在JavaScript中处于中级水平的程序员将学习如何掌握TypeScript。您将看到TypeScript如何帮助您将代码扩展到10倍以上,并使编程再次有趣。



以下摘录自《高级类型》一书的一章。



高级类型



享誉全球的TypeScript类型系统凭借其功能甚至令Haskell程序员感到惊讶。如您所知,它不仅具有表达能力,而且易于使用:类型限制和关系是简洁,可理解的,并且在大多数情况下是自动推导的。



对诸如原型之类的动态JavaScript元素进行建模,绑定,功能重载以及不断变化的对象,都需要一个类型系统和一个像蝙蝠侠一样丰富的类型系统。



在本章中,我将深入探讨子类型,兼容性,差异,随机变量和扩展的主题。然后,我将详细介绍基于流的类型检查的细节,包括细化和整体性。接下来,我将在类型级别上演示一些高级编程功能:连接和映射对象类型,使用条件类型,定义类型保护以及诸如类型声明和显式分配声明之类的后备解决方案。最后,我将向您介绍一些用于增强类型安全性的高级模式:伴随对象模式,元组的接口增强,模仿标称类型以及安全的原型扩展。



类型之间的关系



让我们仔细看一下TypeScript中的关系。



子类型和超类型



我们已经在p的About Types部分中谈到了兼容性。34,所以让我们直接从子类型的定义开始深入探讨这个主题。

图片




返回图。3.1,请参阅TypeScript的内置子类型关联。

图片




  • 数组是对象的子类型。
  • 元组是数组的子类型。
  • 一切都是任何事物的子类型。
  • 从来都不是一切的子类型。
  • 扩展了Animal类的Bird类是Animal类的子类型。


根据我刚刚给一个子类型的定义,这意味着:



  • 无论何时需要对象,都可以使用数组。
  • 无论何时需要数组,都可以使用元组。
  • 无论何时何地,都可以使用对象。
  • 永远无法在任何地方使用。
  • 无论何时需要动物,都可以使用Bird。


超类型与子类型相反。



SUPERTYPE



如果您有A和B这两种类型,并且B是A的超类型,则可以在需要B的任何地方安全地使用A(图6.2)。


图片


再次,基于图。3.1:



  • 数组是元组的超类型。
  • 对象是数组的超类型。
  • 任何是一切的超类型。
  • 从来都不是任何人的超类型。
  • 动物是鸟的超型。


它只是子类型的反义词,仅此而已。



变体



对于大多数类型,很容易理解某个类型A是否是B的子类型。对于简单类型(例如数字,字符串等),可以参考图11中的图。3.1或独立确定联合号中包含的数字| 字符串是此联合的子类型。



但是还有更复杂的类型,例如泛型。考虑以下问题:



  • 数组<A>什么时候是数组<B>的子类型?
  • 表格A什么时候是表格B的子类型?
  • 函数(a:A)=> B什么时候是函数(c:C)=> D的子类型?


包含其他类型的类型(即,具有诸如Array <A>之类的类型参数,具有{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: 123456,
    name: 'Ima User'
}
deleteUser(existingUser)


deleteUser接收一个{id?:数字,名称:字符串}类型的对象,并将一个{id:数字,名称:字符串}类型的existingUser传递给它。请注意,id(数字)属性的类型是预期类型(数字|未定义)的子类型。因此,整个{id:number,name:string}对象是{id?:number,name:string}的子类型,因此TypeScript允许这样做。



您是否看到任何安全问题?有一个:在将ExistingUser传递给deleteUser之后,TypeScript不知道用户ID已删除,因此,如果在删除了deleteUser(existingUser)后读取了existingUser.id,则TypeScript仍将假设existUser.id为数字类型。



显然,使用期望其超类类型的对象类型是不安全的。那么为什么TypeScript允许这样做呢?最重要的是,这并不意味着它是完全安全的。他的类型系统试图捕捉真正的错误并使所有级别的程序员都可以看到它们。由于破坏性更新(例如删除属性)在实践中相对很少见,因此TypeScript是放宽的,它允许您在期望其超类型的位置分配对象。



而相反的情况呢:是否可以在期望其子类型的地方分配对象?



让我们为旧用户添加一个新类型,然后删除具有该类型的用户(想象将类型添加到您的同事编写的代码中):



type LegacyUser = {
    id?: number | string
    name: string
}
let legacyUser: LegacyUser = {
    id: '793331',
    name: 'Xin Yang'
}
deleteUser(legacyUser) //  TS2345: a  'LegacyUser'
                                  //    
                                  // '{id?: number |undefined, name: string}'.
                                 //  'string'    'number |
                                 // undefined'.


当您提交具有其类型是期望类型的超类型的属性的表单时,TypeScript发誓。这是因为id是一个字符串|编号undefined和deleteUser仅处理id为number |未定义。



在期望表单时,可以传递属性类型为期望类型的<:的类型,但是如果属性类型不是期望类型的超类型,则不能传递表单。当谈论类型时,我们说:“ TypeScript形式(对象和类)的属性类型是协变的。”也就是说,为了将对象A分配给对象B,其每个属性都必须<:B中的对应属性。



协方差是四种方差类型之一:



不变性

特别需要的T。

协方差

需<:T. 需要

逆差

>:T。

双方差将

适合<:T或>:T。



在TypeScript中,每个复杂类型的成员(对象,类,数组和函数返回类型)都是协变的,但有一个例外:函数参数的类型是协变的。



. , . ( ). , Scala, Kotlin Flow, , .



TypeScript : , , , (, id deleteUser, , , ).


函数



变化让我们考虑一些示例。



如果A的Arity(参数数量)与B相同或更少,则Function A是函数B的子类型,并且:



  1. 属于A的this类型是未定义的,或者属于B的this类型的>:。
  2. 每个参数A>:B中的对应参数。
  3. 返回类型A <:返回类型B。


请注意,为了使函数A成为函数B的子类型,其类型和参数必须与B中的>:对应,而其返回类型必须为<:。为什么情况会逆转?为什么简单的<:条件不适用于每个组件(类型为this,参数类型和返回类型),就像对象,数组,联合等一样?



让我们从定义三种类型开始(代替类,您可以使用其他类型,其中A:<B <:C):



class Animal {}
class Bird extends Animal {
    chirp() {}
}
class Crow extends Bird {
    caw() {}
}


因此,乌鸦<:鸟<:动物。



让我们定义一个使用Bird并使其鸣叫的函数:



function chirp(bird: Bird): Bird {
    bird.chirp()
    return bird
}


到现在为止还挺好。TypeScript允许您使用什么管道to声?



chirp(new Animal) //  TS2345:   'Animal'
chirp(new Bird) //     'Bird'.
chirp(new Crow)


Bird实例(作为bird类型的线性调频参数)或Crow实例(作为Bird的子类型)。子类型传递按预期方式工作。



让我们创建一个新函数。这次,它的参数将是一个函数:



function clone(f: (b: Bird) => Bird): void {
    // ...
}


clone需要一个函数f,该函数接收Bird并返回Bird。哪些类型的函数可以安全地传递给f?显然,接收并返回Bird的函数:



function birdToBird(b: Bird): Bird {
    // ...
}
clone(birdToBird) // OK


带有Bird却返回Crow或Animal的函数呢?



function birdToCrow(d: Bird): Crow {
    // ...
}
clone(birdToCrow) // OK
function birdToAnimal(d: Bird): Animal {
    // ...
}
clone(birdToAnimal) //  TS2345:   '(d: Bird) =>
                             // Animal'    
                            // '(b: Bird) => Bird'. 'Animal'
                           //    'Bird'.


birdToCrow可以正常工作,但是birdToAnimal会引发错误。为什么?想象一个克隆实现看起来像这样:



function clone(f: (b: Bird) => Bird): void {
    let parent = new Bird
    let babyBird = f(parent)
    babyBird.chirp()
}


通过将函数f传递给克隆并返回Animal,我们无法在其中调用.chirp。因此,TypeScript必须确保我们传入的函数至少返回Bird。



当我们说函数的返回类型是协变的时,这意味着仅当函数的返回类型为<:该函数的返回类型时,该函数才可以是另一个函数的子类型。



好吧,那参数类型呢?



function animalToBird(a: Animal): Bird {
  // ...
}
clone(animalToBird) // OK
function crowToBird(c: Crow): Bird {
  // ...
}
clone(crowToBird)        //  TS2345:   '(c: Crow) =>
                        // Bird'     '
                       // (b: Bird) => Bird'.


为了使一个功能与另一个功能兼容,其所有参数类型(包括该参数)必须为>:另一个功能中其对应的参数。要理解原因,请考虑用户在将其传递给克隆之前如何实现crowToBird?



function crowToBird(c: Crow): Bird {
  c.caw()
  return new Bird
}


TSC-: STRICTFUNCTIONTYPES



- TypeScript this. , , {«strictFunctionTypes»: true} tsconfig.json.



{«strict»: true}, .


现在,如果克隆使用新的Bird调用crowToBird,我们将得到一个例外,因为.caw是在所有Crow中定义的,而不是在所有Birds中定义的。



这意味着函数的参数和这种类型是相反的。也就是说,一个函数只能是另一个函数的子类型,前提是每个函数的参数和类型必须>:它们在另一个函数中的对应参数。



幸运的是,这些规则不需要记住。只要记住它们,当您在某处传递了错误键入的函数时,编辑器会在红色下划线下划线。



兼容性



子类型和超类型关系是任何静态类型语言中的关键概念。它们对于理解兼容性的工作原理也很重要(请记住,兼容性是指TypeScript规则,该规则在需要B型的情况下控制A型的使用)。



当TypeScript需要回答问题“类型A是否与类型B兼容?”时,它遵循简单的规则。对于非枚举类型(例如数组,布尔值,数字,对象,函数,类,类实例和字符串,包括文字类型),如果其中一个条件为真,则A与B兼容。



  1. A <:B。
  2. A是任意的。


规则1只是一个子类型定义:如果A是B的子类型,则在需要B的任何地方都可以使用A。



规则2是规则1的例外,以便于与JavaScript代码进行交互。

对于由关键字enum或const enum创建的枚举类型,如果满足以下条件之一,则类型A与枚举B兼容。



  1. A是枚举B的成员。
  2. B具有至少一个类型为数字的成员,而A为数字。


规则1与简单类型完全相同(如果A是B的成员,则A是B的类型,我们说B <:B)。



为了方便使用枚举,规则2是必需的,因为枚举会严重损害TypeScript的安全性(请参阅第60页的“枚举”小节),我建议避免使用它们。



类型扩展类型



扩展是理解类型推断如何工作的关键。 TypeScript执行宽大,比推导尽可能具体的类型更可能推导更通用的类型。这将使您的生活更轻松,并减少处理类型检查器注释所需的时间。



在第3章中,您已经看到了几个类型扩展的示例。考虑别人。



当您将变量声明为可变变量(使用let或var)时,其类型将从其文字的值类型扩展为该文字所属的基本类型:



let a = 'x' // string
let b = 3   // number
var c = true   // boolean
const d = {x: 3}   // {x: number}
enum E {X, Y, Z}
let e = E.X   // E


这不适用于不可变的声明:



const a = 'x' // 'x'
const b = 3   // 3
const c = true   // true
enum E {X, Y, Z}
const e = E.X   // E.X


您可以使用显式类型注释来防止其扩展:



let a: 'x' = 'x' // 'x'
let b: 3 = 3  // 3
var c: true = true  // true
const d: {x: 3} = {x: 3}  // {x: 3}


当您使用let或var重新分配非扩展类型时,TypeScript会为您扩展它。为防止这种情况,请在原始声明中添加一个显式类型注释:



const a = 'x' // 'x'
let b = a  // string
const c: 'x' = 'x'  // 'x'
let d = c  // 'x'


初始化为null或未定义的变量将扩展为以下任意值:



let a = null // any
a = 3  // any
a = 'b'  // any


但是,当初始化为null或undefined的变量离开声明它的作用域时,TypeScript将为其分配特定的类型:



function x() {
   let a = null  // any
   a = 3   // any
   a = 'b'   // any
   return a
}
x()   // string


const



类型const类型有助于避免扩展类型声明。将其用作类型断言(请参见第185页的“类型批准”小节):



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


const消除了类型扩展,并将其成员递归标记为只读,即使在深度嵌套的数据结构中也是如此:



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


当您希望TypeScript推断尽可能窄的类型时,请用作const。



检查其他属性



当TypeScript检查一种类型的对象是否与另一种类型的对象兼容时,类型扩展也会起作用。



对象类型在其成员中是协变的(请参见第148页的“形状和数组变化”小节)。但是,如果TypeScript遵循此规则而不进行其他检查,则可能会出现问题。



例如,考虑一个可以传递给类以对其进行自定义的Options对象:



type Options = {
    baseURL: string
    cacheSize?: number
    tier?: 'prod' | 'dev'
}
class API {
    constructor(private options: Options) {}
}
new API({
     baseURL: 'https://api.mysite.com',
     tier: 'prod'
})


如果您在选项中输入错误,现在会发生什么?



new API({
   baseURL: 'https://api.mysite.com',
   tierr: 'prod'         //  TS2345:   '{tierr: string}'
})                      //     'Options'.
                        //     
                       //  ,  'tierr'  
                      //   'Options'.    'tier'?


这是一个常见的JavaScript错误,TypeScript可以帮助您捕获它。但是,如果对象的类型在其成员中是协变的,那么TypeScript如何截获它?



换一种说法:



  • 我们期望类型{baseURL:string,cacheSize?:number,tier?:'prod'| 'dev'}。
  • 我们传递了{baseURL:string,tierr:string}类型。
  • 传递的类型是预期类型的​​子类型,但是TypeScript知道会报告错误。


通过检查额外的属性,当您尝试将新的对象文字类型T分配给另一个类型U时,并且T具有U没有的属性时,TypeScript报告错误。



新的对象文字类型是TypeScript从对象文字中推断出的类型。如果此对象文字使用类型断言(请参见第185页的“类型断言”小节)或已分配给变量,则新类型将扩展为常规对象类型,并且其新颖性将丢失。



让我们尝试使此定义更宽敞:



type Options = {
     baseURL: string
     cacheSize?: number
     tier?: 'prod' | 'dev'
}
class API {
    constructor(private options: Options) {}
}
new API({ ❶
    baseURL: 'https://api.mysite.com',
    tier: 'prod'
})
new API({ ❷
    baseURL: 'https://api.mysite.com',
    badTier: 'prod' //  TS2345:   '{baseURL:
}) // string; badTier: string}' 
//    'Options'.
new API({ ❸
    baseURL: 'https://api.mysite.com',
    badTier: 'prod'
} as Options)
let badOptions = { ❹
    baseURL: 'https://api.mysite.com',
    badTier: 'prod'
}
new API(badOptions)
let options: Options = { ❺
    baseURL: 'https://api.mysite.com',
    badTier: 'prod' //  TS2322:  '{baseURL: string;
} // badTier: string}'  
// 'Options'.
new API(options)


with使用baseURL和两个可选属性之一实例化API:层。一切正常。



❷我们错误地将层写为badTier。我们传递给新API的options对象是新的(其类型被推断,与变量不兼容,并且我们不为它键入断言),因此在检查不必要的属性时,TypeScript检测到额外的badTier属性(在options对象中定义),但是不在选项中)。



❸声明无效的选项对象属于选项类型。 TypeScript不再将其视为新的,并从检查额外的属性得出结论,即没有错误。 p的“类型断言”部分中描述了as T语法。 185。



ing将options对象分配给badOptions变量。 TypeScript不再认为它是新的,并且在检查了不必要的属性后得出的结论是没有错误。



explicitly当我们显式键入options作为Options时,我们分配给options的对象是新对象,因此TypeScript检查其他属性并发现错误。请注意,在这种情况下,当我们将选项传递给新API时,不会检查额外的属性,但是当我们尝试将options对象分配给options变量时,它不会进行检查。



您无需记住这些规则。这只是内部TypeScript试探法,可捕获尽可能多的错误。请记住它们,如果您突然想知道TypeScript是如何发现甚至Ivan(公司的老手和专业的代码审查员)都没有注意到的错误的。 TypeScript



细化



执行类型推断的符号执行。类型检查模块使用命令流指令(如if,?,||和switch)以及类型查询(如typeof,instanceof和in),从而在程序员读取代码时对类型进行限定。但是,很少有语言支持此便捷功能。



想象一下,您已经开发了一个用于在TypeScript中定义CSS规则的API,而您的同事想使用它来设置HTML元素的宽度。它通过您要解析的宽度,并在以后检查。



首先,让我们实现一个将CSS字符串解析为value和unit的函数:



//       
//  ,      CSS
type Unit = 'cm' | 'px' | '%'
//   
let units: Unit[] = ['cm', 'px', '%']
//   . .   null,    
function parseUnit(value: string): Unit | null {
  for (let i = 0; i < units.length; i++) {
    if (value.endsWith(units[i])) {
       return units[i]
}
}
     return null
}


然后,我们使用parseUnit解析用户提供的宽度。width可以是数字(可能以像素为单位),也可以是带有附加单位的字符串,或者为null或未定义。



在此示例中,我们多次使用类型限定:



type Width = {
     unit: Unit,
     value: number
}
function parseWidth(width: number | string | null |
undefined): Width | null {
//  width — null  undefined,  .
if (width == null) { ❶
     return null
}
//  width — number,  .
if (typeof width === 'number') { ❷
    return {unit: 'px', value: width}
}
//      width.
let unit = parseUnit(width)
if (unit) { ❸
return {unit, value: parseFloat(width)}
}
//     null.
return null
}


❶TypeScript能够理解JavaScript对null的松散相等性对于null和undefined都将返回true。他还知道,如果支票通过,则我们将进行退货;如果不进行退货,则检查将失败,从那一刻起,宽度类型为number |。字符串(不能再为null或未定义)。我们说类型是根据数字|字符串空|数量不确定|串。



❷typeof检查在运行时要求输入值以查看其类型。 TypeScript在编译时还利用了typeof:在测试通过的if分支中,TypeScript知道宽度是数字。否则(如果此分支确实返回),width应该是字符串-唯一剩余的类型。



❸由于parseUnit可以返回null,因此我们对此进行了检查。 TypeScript知道,如果unit是正确的,那么它在if分支中必须是Unit类型。否则,单位无效,这意味着其类型为null(从Unit | null进行了改进)。



❹最后,我们返回null。仅当用户输入一个字符串作为宽度,但是该字符串包含不受支持的单位时,才会发生这种情况。

我对TypeScript进行的每种类型改进都经历了其思路。 TypeScript在读取和编写代码时将您的推理并将其明确化为类型检查和推理顺序的工作非常出色。



区分联接类型



正如我们刚刚发现的那样,TypeScript对JavaScript的工作原理有很好的了解,并且能够像阅读我们的思想一样跟踪我们的类型限定。



假设我们正在为应用程序创建自定义事件系统。我们首先定义事件类型以及处理这些事件到达的功能。假设UserTextEvent模拟了键盘事件(例如,用户键入文本<input />),而UserMouseEvent模拟了鼠标事件(用户将鼠标移动到坐标[100,200]上):



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
         // ...
         return
   }
         event.value // [number, number]
}


TypeScript知道在if块内部,event.value必须是一个字符串(由于typeof检查),也就是说,if块之后的event.value必须是[number,number]元组(由于if块中的返回)。



并发症将导致什么?让我们为事件类型添加说明:



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 (!!!)
        // ...
        return
   }
  event.value // [number, number]
  event.target // HTMLInputElement | HTMLElement (!!!)
}


尽管优化适用于event.value,但不适用于event.target。为什么?当handle接收到UserEvent类型的参数时,这并不意味着您需要传递UserTextEvent或UserMouseEvent-实际上,您可以传递UserMouseEvent类型的参数| UserTextEvent。并且由于联合的成员可以重叠,所以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
       // ...
       return
   }
  event.value // [number, number]
  event.target // HTMLElement
}


现在,当我们根据其标记字段(event.type)的值来优化事件时,TypeScript知道if分支中应该有一个UserTextEvent,而在if分支之后,它应该具有UserMouseEvent。因为每个联合类型中的标签都是唯一的,所以TypeScript知道他们是互斥的。



在编写处理各种类型的联接类型的函数时,请使用区分联接。例如,在使用Flux动作时,需要进行Redux还原或在React中使用useReducer。



您可以更详细地了解这本书,并可以在出版商的网站上以特价购买



All Articles