TypeScript that can't find North

Since the assistance of TS, JS can be written like a duck to water.

After a long time, there seems to be such a concept in a daze: the type result deduced in the IDE is the type result that the code actually runs.

But if I say: 1 + 1the is string.

You might think I'm bullshitting or making trouble.

Don't go away yet, give me a chance to explain.

"Type Result" vs "Actual Result"

Case number one

Let's start with a simple piece of code:

let foo = 123
type bar = number
复制代码

The first line declares a variable.

The second line declares a type.

They are two syntaxes, but they have one thing in common.

Through the typeof operator, take a look at it in another way:

let foo = 123
type Foo = typeof foo // Foo 的类型是 number
复制代码

The variable foocan be typeofconverted to the barsame number.

Wow, it's amazing.

Cough cough (serious), what I want to explain here is that in the ecology of TS, each variable has its own type, and there is a one-to-one correspondence between them.

But here comes the point, this type relationship is not necessarily accurate :

function foo (value): string {
  return value
}

const bar = foo(1+1)
复制代码

In the above code, although the result of the operation is number, TS tells us that the result of the type is string. (Also fill in the opening hole by the way)

image-20230128150737495

This is actually foobecause the valueparameter type in anythe function stringis anya subset of , so there is no problem with this type deduction.

From this example, there is a vague feeling that TS seems not safe?

case two

Let's do another reading comprehension to deepen the knowledge points:

What is the result of running the following code?

type Foo = {
  foo: {
    bar: string
  }
}
const test = {} as Foo
console.log(test.foo.bar) // 这行结果是什么?
复制代码

As a result, it is not difficult to see that an undefined error will be thrown.

But give it to TS to check, and it will tell you: This code is so well written, there is nothing wrong with it.

image-20230128152547163

If it is as smart as I used to be, I might say: Then it any’s asfine to not allow and , and it’s safe to use TS.

但没有什么是绝对安全的。当我们越相信一个规律的时候,它潜在的破坏力就会越大

事实上,很多场景都无法脱离诸如 anyas 这些不安全的类型工具,我们得在混乱中学会分辨哪些是安全的。这就是 TS 「找不着北」的原因。

类型推导

相信很多同学已经知道什么是「类型推导」了,但还是为一些 TS 萌新简单铺垫一下。

由于部分 JS 语法有显而易见的逻辑关系,所以可以做到一些自动推导的能力。

function foo (value: string): string {
  return value
}
// vs
function foo (value: string) {
  return value
}
复制代码

根据逻辑关系, value 类型是 string,通过 return value,大聪明 TS 就能得知返回的类型是 string

因此,后面的 foo 函数虽然把返回值类型 string 省略掉,但两者在 TS 里的效果依然"相同"(在实践上还是有区别的,但这里就不深入展开了)。

但是存在一些情况,导致 TS 无法顺利推导出我们期望的结果

为什么这么强调自动推导呢,原因有二:

  1. 如果什么都要人工干预,那不是智能,是智障。
  2. 只要是人为的,就有可能出错。

由于 JS 特性 导致无法自动推导预期类型

因为 JS 语法过于 难以理解 灵活,有些时候 TS 无法推导出开发者的意图:

const foo = {
  bar: [] // foo.bar 的类型是 any[]
}
复制代码

由于第二行的 bar 声明时未指明是什么数组,TS 内心也很绝望,JS 先支持的这种 sb 便捷语法,那自己只能把它当成 any[]来处理了。而any的危害大家都有所耳闻,这里就放进去一只害群之马。

但作为开发者,这种声明代码,肯定不会为了让大聪明 TS 方便理解,给数组里塞一个目标类型的值进去。

这里处理方法有两种:

const foo: {
  bar: string[]
} = {
  bar: []
}
复制代码
const foo = {
  bar: [] as string[]
}
复制代码

这种场景,更推荐第二种,语法简练 (这就是为什么不要一杆子打死 as 的原因)。但千万不要滥用 as用了也别说是我教的

还有一个更常见的场景:

JS 里的 Promise 由于语法原因,then 里面的参数没法和上文的 resolve 形成关联。因此 then 流程里面的参数是 unknowncatch 流程里的参数是 any

new Promise((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 类型是 unknown
}).catch(err => {
  err // err 类型是 any
})
复制代码

调教方式如下:

new Promise<number>((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 类型是 number
}).catch((err: number) => {
  err // err 类型是 number
})
复制代码

由于 平台特性 导致无法自动推导预期类型

对于跨平台,其实不论是 JS 还是 Java,系统都不知道对面会传来个什么玩意,算是通病。

但是因为 JS 过于 渣男 优秀,在哪都能见到他。因此「跨平台」在这是个更为广义的概念,不仅在外部隔着网线(Fetch Api),在内部(DOM、BOM、WASM等)也不消停。因此更为常见。

在跨平台的场景,如果 JS 都不知道是啥,TS 更是无法推断是啥玩意。

Fetch Api 场景:

fetch('/api').then(res => {
  return res.json()
}).then(data => {}) // data 的类型是 any
复制代码

序列化场景:

let foo = { bar: 123 }
localStorage.setItem('foo', JSON.stringify(foo))
let _foo = JSON.parse(localStorage.getItem('foo')!) // _foo 的类型是 any
复制代码

有的同学可能会问:我在使用 DOM Api 的时候,TS 也给了提示呀,很好用,为啥还说无法自动推导呢?

答:那是因为微软的工程师帮你负重前行。

由于 TS 逻辑问题 导致无法自动推导预期类型

这个标题是什么意思呢?

可以理解算是 TS 的 issue。

有的是可以修复的,属于特定版本可复现,这里就不谈了。

还有的是"无法修复"的,属于逻辑上的缺陷:

猜一猜 bar 是什么类型?

const foo: (undefined | string)[] = []
const bar = foo.filter(item => !!item)
复制代码

答案揭晓:

image-20230128165215312

bar 并没有因为 filter 内逻辑的存在,而收敛了类型。

解决方法:

通过「类型断言」

const foo: (undefined | string)[] = []
const bar = foo.filter((item): item is string => !!item) // bar 的类型是 string[]
复制代码

将通过 filter 的值类型全部断成 string,问题勉强解决。

还有"无解"的案例:

const foo: ReadonlyArray<string> = []
if (Array.isArray(foo)) {
  foo // 在这个大括号里,foo 的类型是 any
}
复制代码

Array.isArrayReadonlyArray 配合使用时,判断是数组的时候会导致内部原本是数组的类型变为 any。

下面是 Array.isArray 的类型实现:

interface ArrayConstructor {
    // ...
    isArray(arg: any): arg is any[];
    // ...
}
复制代码

简单来说就是 ReadonlyArray 是个特殊的"数组",在条件类型里,无法正常取出它泛型内的类型。

可以用这个函数改写做兼容:

function isArray (arg: any): arg is ReadonlyArray<any> {
  return Array.isArray(arg)
}
复制代码

但总归不优雅。

另外有兴趣的可以看下这个讨论,官方都闲置7 年了...。

全局类型 vs 全局变量

TS 中的两个全局的概念也容易找不着北。

这里主要就是涉及声明文件相关的知识点了。

这是声明一个全局类型:

// index.d.ts
interface GlobalType {
  foo: string
}
复制代码

这是声明一个全局变量:

// index.d.ts
declare const globalFoo: string
复制代码

乍一看之下,好像明白了,又好像什么都不明白。

image-20230129144542413

声明全局类型

全局类型好理解,但问题在于「全局」和「局部」的区别。

有一个关键的细节:

下面是全局类型

// index1.d.ts
interface GlobalType1 {
  foo: string
}
interface GlobalType2 {
  foo: string
}
复制代码

下面是局部类型

// index2.d.ts
export interface GlobalType1 {
  foo: string
}
interface GlobalType2 {
  foo: string
}
复制代码

可以看到,就是第二行有无 export 的区别。

差别就在这里,只要当前文件存在任意一个 export,里面定义的所有类型都是局部类型。

比如 index2.d.ts 里面,GlobalType2 没有 export,但它也是局部类型。

声明全局变量

接下来是声明全局变量,它容易和 JS 里的「全局变量」混淆。

做一个不存在的假设:当前全局作用域在 window 上,它对应的类型是 IWindow

我们可以在代码中直接敲出对应关键字来访问挂载在 window 上的属性,在类型里也就是写在 IWindow 上的那些属性。

Array.isArray([]) // Array 就是全局变量,假设 IWindow 上定义了 Array
复制代码

声明全局变量,可以类比是在 IWindow 里扩展了一个属性,但是它并不存在于 window 实例上,要自己用额外的代码去实现。

interface IWindow {
  globalFoo: string // 添加了一个 globalFoo 的属性
}
复制代码

回到真实的 TS 中。

根据上文,实现全局变量分两步:

第一步,全局类型上占个坑位 (这里要满足全局类型的条件,也就是不能存在 export)

// index.d.ts
declare const globalFoo: string
复制代码

第二步,JS 中实现对应功能

// index.ts
window.globalFoo = '123'
复制代码

这样,就可以直接在代码里访问 globalFoo,即存在类型,也不会在运行代码的时候是 undefined

npm 包里面的话,这俩还会有差异,不过大部分情况下用不上,有兴趣的同学自己看文档呗~~(绝不是因为偷懒)~~。

与 TS 和谐的生活

TS 虽然有这么多毛病,但不妨碍人类用它做出一些"伟大"的产品。

image-20230128170829852

不知道在哪个时间节点,我们发现 IDE 变得智能很多。就算用的只是 JS,也有很多智能提示。

比如 ES6 的语法、DOM 的语法,甚至是 React、Vue、lodash 等第三方包 ,敲一下键盘,就能得到后面需要的东西,写错了也会提示自己。

image-20230129162349203

现在我们知道了,它的背后是 TS 的声明文件的功劳,也知道了 TS 其实不那么好用,但是却不影响用它的人能无缝享受到它带来的生产力的提升。

在这背后是「封装」的理念。

就算在一些无法通过推导得到类型的场景,我们可以简化这个过程,只用类型描述输入和输出,再和对应 JS 代码关联上,忽略亿点点细节。

就像我们无缝使用 DOM Api 一样,甚至没有察觉背后有声明文件的存在。

TS 的本质是一个辅助工具

以下是 TS 官网上关于 ThisType 类型工具的示例,通过它可以实现类似 Vue 选项式语法上下文作用域的关联:

// 这只是一个例子,为了感受封装性,并不需要看懂(个人认为这是 TS 里最复杂的运用方式之一)。
type ObjectDescriptor<D, M> = {
  data?: D;
  methods?: M & ThisType<D & M>; // Type of 'this' in methods is D & M
};
 
function makeObject<D, M>(desc: ObjectDescriptor<D, M>): D & M {
  let data: object = desc.data || {};
  let methods: object = desc.methods || {};
  return { ...data, ...methods } as D & M;
}
 
let obj = makeObject({
  data: { x: 0, y: 0 },
  methods: {
    moveBy(dx: number, dy: number) {
      this.x += dx; // Strongly typed this
      this.y += dy; // Strongly typed this
    },
  },
});
 
obj.x = 10;
obj.y = 20;
obj.moveBy(5, 5);
复制代码

在第 17 行的 moveBy 函数体内,可以通过 this 访问在第 14 行定义的 data 对象类型,得到看似毫无关联的 xy 的类型。

是不是和 Vue 的语法很像?

但不需要实现框架细节,就能够得到预期类型的关系。

这也使得 TS 更像一个"外挂",也是我自己一直强调的:TS 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码

因此,我们借助类型这个工具,可以实现很多「特别」的效果,比如:

  • 在使用 Event 时,自动显示可用的事件,以及 callback 的类型:更好维护的发布订阅模式的应用

  • 调用接口之后拿到的数据,将不靠谱的数据类型定义为「可选」,让 TS 帮我们判断什么时候要补充默认值:

    interface ApiGetData {
      status: number
      data: {
        list?: string[] // 让系统告诉我们使用时要兼容 undefined 的情况
      }
    }
    复制代码

image-20230128174509703

  • 通过包装一个 helper 函数承载类型计算,然后用 ts-ignore 让它强制通过,实现任意想要达到的类型组合效果:

    假设有一个函数,传入一个url,自动帮我补全所有其他默认参数

    interface MixinType {
      methods: 'GET' | 'POST'
      timeout: number
    }
    
    const apiHelper = function <T extends { url?: string }>(
      arg1: T
    ):
      T extends { url: string } ? 
        () => T & MixinType // 在这行写任意想要的结果类型
        : () => any
    {
      // @ts-ignore
      return arg1
    }
    
    const getFetchArgs = apiHelper({
      url: '/get/sth',
    })
    复制代码

如下所示,通过apiHelper生成的函数,返回值就是我们预先定义的 MixinType 里的内容。

image-20230128175225167

低可读性

由于 TS 类型不像 JS 代码那样可调试,泛型传递链又臭又长,难以追溯,因此对于一些复杂实现的类型代码,可读性差几乎是常态。

image-20230129143211249

// ps: 不需要看懂这段代码
type EventNameTransFunction<
    EventFunctionTypes extends Record<string, IAnyFunction> = Record<string, IAnyFunction>,
    EventKeyTypes extends Record<string, string> = Record<string, string>,
> = {
    [T in keyof (EventKeyTypes&EventFunctionTypes)]: (EventKeyTypes&EventFunctionTypes)[T] extends IAnyFunction ?
        (EventKeyTypes&EventFunctionTypes)[T] :
        IAnyFunction<void>
}
复制代码

上面这段类型计算的逻辑实现了某个自定义的类型需求,但是一眼就懵,并且很难调试。

在 Vue、React-Redux 等框架的类型定义文件里,充斥了大量这种代码。

越深度使用 TS,写越多定制化的功能,越容易出现这种代码。也许自己还能勉强看懂,丢给别人,那内心是真的会问候对方的。

解决方法,就是封装。把一些复杂的东西隐藏起来,暴露良好的外部接口。比如,React 声明文件也一样复杂,但是我们并不需要关心怎么实现这些类型能力的,偶尔看看类型接口传参就行。

和找不着北的 TS 和谐的生活在一起。

一些实践技巧

压轴部分,分享一些上文没提到的其他实践经验,可以避免更多「找不着北」的问题。

只允许初始化场景的「类型断言」

「类型断言」上文出现过,它的威力非常强大,可以将某个类型强制关联到某个变量头上,比如:

const foo = [] as string[] // 这里用了 as 操作符
const bar = <{ foo: string }>{} // "<T>{}" 等同 "{} as T"
function isBoolean(args: any): args is boolean { return typeof args === 'boolean' } // 这里用了 is 操作符
复制代码

上文有讲到,类型错误带来的危害。「类型断言」是一种改变类型的方法,因此在使用的时候一定要明白自己在做什么

很多同学在用类型断言只是为了粗暴解决 ts error,这种情况一定是禁止使用类型断言的,造成的烂摊子如果让别人收拾,真的是会有想打人的冲动。

function getValue() {
  const value: string = ''
  return Math.random() > 0.5 ? false : value
}
// bar 有 boolean 和 string 两种可能,但为了解决 ts error,断言成 string 类型
const bar = getValue() as string
bar.trim() // 运行代码,有报 TypeError 的风险
复制代码

上面的代码只是一个例子,实际情况常见于一些复杂结构的类型当中。但原因是一样的。

我个人认为,能安全且推荐使用类型断言的只有一个场景:初始化。

const foo: {
  bar: string[]
} = {
  bar: []
}
复制代码

上面这段代码,常见于 react 或者 store 当中。新增一个变量还得对应新增类型,得维护两个地方。

用「类型断言」可以很好的解决这个问题:

const foo = {
  bar: [] as string[]
}
复制代码

在一些特殊的场景,因为 TS 的语法问题,只能用「类型断言」去解决,但前提是知道自己在做什么,以及多想一想,多 google 查一查,透过现象看本质,是否有其他更安全的做法。

禁止未赋值的初始化变量

未初始化的赋值,在 TS 的一些场景里直接使用不会报错。

let foo: { bar: string }

function test () {
  foo.bar // 这里 TS 不会报错,但实际上 foo 是 undefined
}
test() // 运行报错
复制代码

这里主要靠 ESLint 去帮我们检查。

"init-declarations": "error"
复制代码

在较为复杂的初始化,无法一行解决,就用函数去给变量初始化。

let foo: { bar: string } = init()

function init() {
  // ...
  return {
    bar: ''
  }
}
复制代码

用 解构赋值 代替 Object.assign

一个 TS 变量的类型在确定之后,后面就无法再变更了。

const bar = {
  foo: 123
}
Object.assign(bar, {
  vvv: 456
})
bar.vvv // 这里会有 ts error
复制代码

因此上面这段代码,虽然用 Object.assign 相比 bar.vvv = 456 而言,拓展属性不会报 TS 错误,但是 bar 的类型并没有拓展,导致下文访问 bar.vvv 提示 ts error。

image-20230128181139313

实际使用当中,可能会导致更为严重的情况:

const bar = {
  foo: 123
}
Object.assign(bar, {
  foo: '123' // 类型不匹配,但 ts 不报错
})
// ... 省略亿点点业务代码
bar.foo.toFixed(2) // 上文已经更改为 string 类型,但这里 ts 不报错
复制代码

这个问题常见于使用 TS 的新手当中。想要将 bar.foo 赋予其他类型的值,但是直接赋值会报错,不知道怎么解决,但发现用 Object.assign 不会触发,就以为是安全的用法。

但是在下文当中,都只会以为 bar.foo是 number 类型,在真实运行的时候就有概率出现 js error 等更为严重的问题。

image-20230128181621379

推荐在代码里禁用 Object.assign。如果要拓展对象,就新建一个,不要去更改原来的。

代码如下:

const bar = {
  foo: 123
}
// 新建一个对象去拓展原来的对象
const _bar = {
  ...bar, 
  foo: '123'
}
_bar.foo.toFixed(2) // ts 报错这里代码有问题,符合预期
复制代码

在达到拓展类型的目的的同时,确保了类型安全。

亦或者,封装一个安全的 assign :

function assign<T>(a: T, b: Partial<T>): T {
  return Object.assign({}, a, b)
}
复制代码

但还是推荐一巴掌拍死 Object.assign。比起应该用什么,不应该用什么更容易记得。毕竟又不是找不到替代方案。

寻找支持的泛型接口

前面有介绍过,有很多原因导致类型无法自动推导,成为了 any。

其实开发者也有意识到这个问题,会提设计泛型接口,补充相应的能力。

比如 Promise,它接受一个泛型,作为 then 的参数类型。

new Promise<number>((resolve, reject) => {
  return Math.random() > 0.5 ? resolve(123) : reject(456)
}).then(data => {
  data // data 的类型是 number
})
复制代码

比如 React 类组件,它第一个泛型参数是指定 this.props 的类型,第二个泛型参数是指定 this.state 的类型。

class Test extends React.Component<{
  test: string // 第一个泛型参数,决定 this.props 是什么类型
}, {
  foo: number // 第二个泛型参数,决定 this.state 是什么类型
}> {
  bar = () => {
    this.props.test // string 类型
    this.state.foo // number 类型
  }
}
复制代码

需要注意的是,不论是 Promise,还是 React,这些泛型参数都是开发者人为设计的,不是内置的,都能找到源代码,自己也可以实现。

因此,也存在设计的不是那么好的,比如 JSON:

JSON.parse<number>('123') // 不支持泛型,触发 ts error
复制代码

以它为例,我们可以自己套个马甲,封装一个对开发者更友好的 JSONParse 函数:

提供一个泛型参数 T,支持定义返回值的类型。

function JSONParse<T> (str: any) {
  return JSON.parse('123') as T
}
JSONParse<number>('123') // number
复制代码

或者直接拓展 TS 的类型库:

interface JSON {
  parse<T = any>(text: string, reviver?: (this: any, key: string, value: any) => any): T;
}
JSON.parse<number>('123') // number
复制代码

或者暴力一些(不推荐):

JSON.parse<number>('123') as number
复制代码

需要声明的是,这里使用「类型断言」,是因为我明白自己在做什么,并且为可能的结果负责。

后记

还是那句话,TS 本质是一个工具,有一些缺陷,但不妨碍它帮助我们写出更好维护代码。

如果以后有更好的工具,我会毫不犹豫的拥抱新的工具,来帮助我写出更少缺陷,更好维护的代码。

Guess you like

Origin juejin.im/post/7194237533076029497