手把手教你在项目业务中实战类型体操

481 阅读16分钟

题图摘自《你为什么不使用 TypeScript?》 作者: 杨健,来源: 知乎

前言

类型系统在我们日常的开发过程中越来越重要了。在Python/Ruby/JS等动态类型语言大火之后,2000年后的语言基本都带上了灵活、富有表达力而且不啰嗦的静态类型(如Scala/Rust/Kotlin等),而类型系统本身也以gradual type的形式重新回到动态语言(e.g. Typed Ruby/TypeScript)中。

在一个现代类型系统里,我们能静态表达的逻辑得到了很大的扩展。而随着类型系统表达力增强,我们可以通过组合各种类型,利用类型系统能力或绕开其限制,最终构造出非常复杂的类型来表达逻辑。这个过程就被戏称为类型体操

类型系统的好处

首先我们讲讲类型系统的好处。

开发体验

代码补全是类型系统给我们开发的最大恩惠。按完dot后自动弹出智能补全框的愉悦不亚于在峡谷抢到人头。补全信息一般需要判断字段访问的对象类型,通过类型收集补全的候补。因此没有静态类型(往往伴随来的是许多元编程,如反射)就很难做到准确的补全。

添加图片注释,不超过 140 字(可选)

在一些更硬核的小众语言中,还可以通过类型系统进行代码生成,比如Idris有的 interactive editing

另一个开发体验的优化是类型即文档。类型在某种程度上来说是对程序的标注。比如类型签名包含了代码输入与输出的信息,也表达了函数的需求,往往我们通过签名就可以猜出一个函数的用途和用法。比如在RPC框架(gRPC/thrift)调用的IDL中就包含了类型信息。我们看下service的method名字和类型,就能大概猜测出代码。

添加图片注释,不超过 140 字(可选)

独立于程序存在的文档往往会因为代码更新而过期或不准确。而类型标注由于和代码绑定,它的正确性是被编译器保障的。

程序运行优化

动态类型不利于编译器输出优化代码。比如在JavaScript/Python中做加法,必须考虑到操作对象的类型转换或者不同类型的运算符重载。

// for example the following code
value1 + value2
// is equivalent to 
toPrimitive(value1) + toPrimitive(value2)
// where toPrimitive is 
function toPrimitive(val) {
  if (isPrimitive(val)) return val
  if (isObject(val)) {
    let v = val.valueOf()
    if (isPrimitive(v)) return v
  }
  let s = val.toString()
  if (isPrimitive(s)) return s
  throw new TypeError
}

// while in a typed language, add will simply be several hardware instructions

另一个程序运行优化是内存安全。弱类型的语言(如C,或者一部分C++),是无法做出安全的垃圾回收器的。比如一个结构体中包含了一个特定类型的指针,在类型强转后变成了整型或者其他类型的指针。那么在访问对象数据或类型的时候就会出现内存访问错误或者回收了错误的对象。

错误检查/程序验证

这点大家或多或少有体会/感觉,静态类型语言中可以在代码上线前解决掉许多低级错误,比如引用一个不存在的变量,或是访问一个不存在的属性。

更进一步的,类型系统可以保证我们一部分错误逻辑不会发生。比如我们能通过类型系统,保证对数据的操作只会在数据状态正确的时候发生。举一个简单TypeScript的例子是,我们只能打开关着的灯,关掉开着的灯。我们可以将灯是否打开的状态编码为类型------开着的灯是 LightOn,而关掉的灯是 LightOff。这两个类只有不同的方法来,以返回新电灯类型的方式操作开和关的状态。

abstract class Light {
  abstract status: 'ON' | 'OFF'
}

class LightOn extends Light {
  status: 'ON' = 'ON'
  turnOff(): LightOff {
    return new LightOff()
  }
}

class LightOff extends Light {
  status: 'OFF' = 'OFF'
  turnOn(): LightOn {
    return new LightOn()
 }
}

let lightOff = new LightOff
let lightOn = lightOff.turnOn()
lightOn.turnOff()
// it's impossible to turn a light on again
// lightOn.turnOn() // error

由于LightOn上没有turnOn方法,所以我们能保证已经开了的灯没法再开一次。这个正确性是由类型检查器(或者编译器)在编译时期完成的。我们在运行程序之前就证明了电灯状态的正确性。

怎么把一些业务逻辑编码到类型上,就是今天要展开的重头戏。和一般网上教程不同,本文会对构造过程进行一一拆解,传授构造复杂类型的思路。学会一个招式是不够的,学会招式间的组合思路才能做到无招胜有招。

场景描述

在To C业务中,我们需要时刻注意,绝对不能把用户敏感信息输出到公开的接口上,比如:打印到日志、存储到非保密数据库、明文做数据请求。

为了防止敏感信息泄露,往往需要多层次各个方面的数据安全防控。比如设置硬件加密保护,存储加密,接口扫描敏感字段名字或代码审核。

举例来说,接口扫描一般会监听http/rpc接口的数据字段名字,通过经验(heuristics)判断一个字段名是不是敏感。比如user_avatar这样的字段名一般是敏感的,就可以把包含这个字段名的接口标记为敏感。但是扫描并不能证明所有代码接口都被扫描到,同时也有非敏感字段误判为敏感字段的情况(比如商业机构的name字段被误判为用户名)。

我们有没有可能在代码编写上多加一层保护,使得敏感数据不会应用在公开的接口或日志中呢?(注意这里我们并不能去掉扫描,代码保护只是补充,一个安全系统需要多重的防护)。

答案是肯定的!通过TypeScript的类型组合,我们可以得到一个好用的泛型类型别名!

构造过程

我们的思路是这样的:

  • 首先我们可以在类型上标注出哪些字段是敏感的(这样的工作可以在定义业务数据类型、申明API接口的时候标注)。
  • 接着,我们可以再想办法让敏感类型不能被函数使用。如果用了话就报错。
  • 最后我们考虑下,怎么把这个接口泛化,用在任意的函数参数上。

要达到这些目的,我们得首先了解下TypeScript的基本功能和常见套路,然后把它们组合起来就可以了。

标注敏感字段

我们第一步要考虑怎么能标注任意类型的数据为敏感的,比如用户年龄是整型,而用户名是字符串。我们是否有办法有通用方案来表达一个数据是不是敏感的呢?

我们可以给所有数据都加上一个字段来代表它是敏感的,但是这个字段在运行时不存在。这个技巧就叫幻影类型(Phantom type)。在理解幻影类型的之前,我们要理解下运行时和编译时的差异。

运行时(run time) vs 编译时(compile time) 编译时: 从源代码变成可执行代码的阶段,这个阶段仅仅是编译器对代码进行处理。比如TypeScript中类型检查和转换代码就叫编译时。 运行时:可执行代码在运行指令的阶段,对于JavaScript而言就是在JS引擎上运行的阶段。 TypeScript的类型仅仅在编译时做类型检查,不出现在可执行代码。所以可以说TS的类型是只在编译时生效的概念。

type Sensitive<T> = T & {
 readonly  '@@sensitive': unique symbol
}

在这里,我们申明了一个带参数的类型别名。这个别名本身是个交叉类型,意味着Sensitive不仅是一个类型T,还有一个字段叫 @@sensitive 。而这个senstive字段就是我们对敏感数据在类型上打的标注。

交叉类型

交叉类型: 是将多个类型合并为一个类型。 这让我们可以把现有的多种类型叠加到一起成为一种类型,它包含了所需的所有类型的特性。 例如,Person & Serializable & Loggable 同时是Person 和Serializable和Loggable。 就是说这个类型的对象同时拥有了这三种类型的成员。

注意到,这个标注仅仅是个编译时产物,它不会带到运行时上。我们在源码里看得到,但是在运行的时候摸不着,如同幻影一般,所以我们也叫他phantom type。

那如何生产一个敏感数据呢?既然在运行时我们不会带上数据。我们可以用一个强转在编译时把它准换掉

function makeSensitive<T>(t: T): Sensitive<T> {
  return t as any as Sensitive<T> // cast
}
let password = makeSensitive('1145141919810') // password is an sensitive data
password = '123456' // error, string is not Sensitive<String>

为什么一个强转就可以了呢?原因是幻影类型在运行时无任何副作用,我们仅仅在编译时把它当标注,这里的强转就是把一个普通的数据类型标注成了敏感类型。

保证敏感类型不能被使用

我们已经有了标注敏感类型的能力,那么如何让一些消费数据的地方不接受敏感类型呢?我们在TypeScript只能表达一个类型和另一个类型不兼容。比如只能保证非敏感字段不能赋值给敏感字段,但是敏感字段Sensitive本身是一个交叉类型,是和T兼容的。

let privateFact = makeSensitive('Sheep loves caicia7716')
let publicFact = 'Rem is the best girl'
privateFact = publicFact // ERROR
// public data cannot be assigned to private data because publicFact does not have the phantom field @@sensitive
publicFact = privateFact // OK
// private data can be assigned to public data because Sensitive<string> is assignable to string

条件类型

这个难点我们可以使用条件类型(conditional type)解决。

TypeScript 2.8引入了条件类型,它能够表示非统一的类型。 条件类型会以一个条件表达式进行类型关系检测,从而在两种类型中选择其一: T extends U ? X : Y 上面的类型意思是,若T能够赋值给U,那么类型是X,否则为Y。 条件类型 T extends U ? X : Y或者解析为X,或者解析为Y,再或者延迟解析,因为它可能依赖一个或多个类型变量。

举个例子,我们如果有函数是

declare function f<T extends boolean>(x: T): T extends true ? string : number;
f(true) // string
f(false) // number

函数返回类型会随着T的不同而发生改变。

另外一个使用的例子是在编译器得到类型的名字,我们可以用字面量类型来表达。

type TypeName<T> =
    T extends string ? "string" :
    T extends number ? "number" :
    T extends boolean ? "boolean" :
    T extends undefined ? "undefined" :
    T extends Function ? "function" :
    "object";

type T0 = TypeName<string>;  // "string"
type T1 = TypeName<"a">;  // "string"
type T2 = TypeName<true>;  // "boolean"
type T3 = TypeName<() => void>;  // "function"
type T4 = TypeName<string[]>;  // "object"

注意到上面的"string"等是字面量类型,不是字符串。

回到刚才的例子中,我们可以判断下,一个类型是不是敏感类型,如果是的话,那么就用条件类型解析成一个不可能存在的类型,否则的话就返回类型本身。

在TypeScript中,never就是永远不可能存在的类型,他代表了那个类型的值永远不可能在运行时出现。

never 类型

这里我们用到了一个概念叫never。

TypeScript 2.0引入了一个新原始类型​never​。​never​类型表示值的类型从不出现。具体而言,​never​是永不返回函数的返回类型,也是变量在类型保护中永不为true的类型。 never类型具有以下特征: - never是所有类型的子类型并且可以赋值给所有类型。 - 没有类型是never的子类型或能赋值给never(never类型本身除外)。 - 在有明确never返回类型注解的函数中,所有return语句(如果有的话)必须有never类型的表达式并且函数的终点必须是不可执行的。

一些返回never函数的示例:

// 函数返回never必须无法执行到终点
function error(message: string): never {
    throw new Error(message);
}
let a: never 
a = 123 // error, nothing else can be assigned to never
a = error('never here') // ok, only never can be assigned

我们可以写一个条件类型,如果它的参数是敏感类型的话,就返回一个永远不可能存在的类型,否则对于非敏感类型我们照旧使用。

type NoSensitive<T> = 
    T extends Sensitive<{}>  // if type T is Sensitive
      ? never               // then it will `never` be`NoSensitive`
      : T                   // 

type UserAge = Sensitive<number>
type Loggable = NoSensitive<number> // number
type Secret = NoSensitive<UserAge> // never

但是,这个判别只会针对敏感类型本身,如果一个类型本身不敏感,但是它的字段里,或者嵌套的字段里有敏感数据怎么办呢?我们可以用映射类型(Mapping type)!

映射类型

TypeScript提供了从旧类型中创建新类型的一种方式 --- 映射类型。 在映射类型里,新类型以相同的形式去转换旧类型里每个属性。 例如,你可以令每个属性成为readonly类型或可选的。 下面是一些例子:

type Readonly<T> = {
    readonly [P in keyof T]: T[P]
}
type Partial<T> = {
    [P in keyof T]?: T[P]
}

像下面这样使用:

interface Person {
  name: string
}
type PersonPartial = Partial<Person> // {name?: string}
type ReadonlyPerson = Readonly<Person> // { readonly name?: string }

依样画葫芦,我们可以对嵌套的对象类型进行去敏感数据的操作

type NoSensitive<T> = {
  [K in keyof T]: NoSensitive<T[K]>
}

这里我们判断,对类型T的所有字段,如果一个字段是本身就敏感类型的话返回never,否则递归去掉那个字段上的敏感信息。

但是要注意,对于JS的原生数据类型我们需要直接返回。于是整合条件类型和映射类型就得到了如下。

type Primitives = 
    number | string | boolean | null | 
    undefined | symbol | Date | RegExp

type NoSensitive<T> = 
    T extends Sensitive<{}> ? 'No Leak' :
    T extends Primitives ? T :
    {
        [K in keyof T]: NoSensitive<T[K]>
    }
interface UserData {
  name: string
  ageGroup: Sensitive<string>
}
function usePublic(t: NoSensitive<UserData>) {}

// error
// ageGroup is never, Sensitive<string> cannot be assigned to never
usePublic({ 
  name: 'Sheep',
  ageGroup: makeSensitive('middle aged man')
})

至此,我们就保证了敏感类型一旦加上NoSensitive的检查后,就永远不能(never)被使用了。

泛化类型接口

虽然我们有了​NoSensitive​,但是我们在代码里使用它还不够方便。我们可以有一个logToStdout的函数,接受任何参数。

declare  function logToStdout(data: object): void

在这里我们用NoSensitive就不能判断是不是传了敏感数据了,因为数据类型被擦除了。我们需要用泛型来捕获传入参数的类型,但同时我们又希望它有界限,使得它不会接受敏感参数(无论它是否藏在参数数据的深处)。这个我们可以给函数加上一个类型上界。

F-bounded polymorphism

那么这个类型上界应该是怎么样的呢?

declare  function logToStdout<T extends NoSensitive<T>>(data: T): void

这里NoSensitive这个bound引用了前面声明的T。如果一个类型T的上限引用了T本身,我们就叫做_F-bounded polymorphism_。这样的写法,我们既能够获得data调用时候的类型,也能按照T给到特定的约束。那么我们这样写可以吗?

很可惜,还差一点

添加图片注释,不超过 140 字(可选)

这种写法会产生Circular constraint!为什么呢?因为NoSensitive这个类型变量直接引用了T。泛型上界不能自己限制自己吗?

绕过类型系统限制

答案是可以的,但是我们要一些技巧绕过类型系统的限制。

了解类型系统是我们表达业务逻辑的难点。

在使用类型表达复杂逻辑的时候,我们往往会需要用特定写法才能利用类型系统的特点,或者会碰到类型系统的限制,需要我们用特殊写法规避。这种写法就如同体操运动有技术动作得分点一样,一定要把动作做出特定姿势或幅度才能得分。这也是为什么我们会戏称编写复杂类型为类型体操。

F-bounded polymorphism中的上限,在递归引用参数的时候必须要多一层准发

这一点和递归类型别名的规则一致,比如

type ListNode = 
  | {type: 'nil' }
  | {type: 'cons', next: ListNode }

定义一个递归类型的时候,它不能直接引用它自己,但是可以在类型别名引用的对象字段中包含自身。

我们利用可以在字段中递归这点,并再利用之前提到的映射类型,就可以处理嵌套字段中含有敏感类型的问题。

type Sanitized<T> = {
    [K in keyof T]: NoSensitive<T[K]>
} & {
    readonly '@@sensitive'?: never
}

这里还要注意一种情况:即Sanitized的参数T本身就是敏感类型,而不是包含的字段有敏感数据。我们可以直接用个和@@sensitive同名的类型为never的可选字段去掉这种情况。

type Sensitive<T> = T & {
    readonly '@@sensitive': unique symbol
}
type Primitives = number | string | boolean | null | undefined | symbol | Date | RegExp
type NoSensitive<T> = 
    T extends Sensitive<{}> ? never :
    T extends Primitives ? T :
    {
        [K in keyof T]: NoSensitive<T[K]>
    }

type Sanitized<T> = {
    [K in keyof T]: NoSensitive<T[K]>
} & {
    readonly '@@sensitive'?: never
}
// usage
declare function makeSensitive<T>(t: T): Sensitive<T>

// guard our data by apply the Sanitized type alias!
declare function logToStdout<T extends Sanitized<T>>(t: T): T 

logToStdout({
    nickname: 'Sheep', 
    password: makeSensitive('loves caicai7716'), // leak!
    location: {
        country: 'CN',
        city: makeSensitive('SHA') // leak!
    }
})

const USSecret = makeSensitive('Donal Trump is a D*uche')
logToStdout(USSecret) // Shhhhhh

完成!效果如下,注意编译报错的红线。

添加图片注释,不超过 140 字(可选)

至此,我们就完成了一个编译时的敏感数据检查类型!

  • 它的API足够简洁,在函数调用的时候多加一个类型上限就可以。
  • 它的功能足够强大,可以查找出数据类型中藏在深层的敏感字段。
  • 它也足够轻量,一切操作都是在编译时完成的,对运行时的性能基本没有开销。

结语

希望通过这篇文章,能帮助你入门Type-level programming。实际上,类型体操的思路仍然和一般编程(尤其是函数式编程)一样,是思考如何通过组合来表达出更多逻辑。两种组合的区别只是它们发生的时机,一个是编译时一个是运行时。而组合的技巧能有多强,就是看对类型系统有多了解了,就如同我们对运行平台(如浏览器,JS引擎,操作系统)有多了解一样。