一点小小的类型安全震撼

引子

经常写 TypeScript 的都知道,写类型容易,让类型安全难。

ts 复制代码
function isString(x: string | number): x is string {
  return typeof x === 'string'
}

在学习 TypeScript 的过程中你可能就见到过 is 这个特殊的语法了,在这里就不对他进行过多的介绍了,我们主要来讲讲使用过程中可能遇到的问题。

如果觉得阅读吃力,我在文末提供了相关名词的一些文档来进行理解,可以进行参考着对应文档进行阅读。

有啥问题

有时候我们会去过滤一个数组,比如说这样:

ts 复制代码
const onlyStrings = ['1', null, '2', 3]
  .filter(item => typeof item === 'string')

Playground

但是这样子对于 TypeScript 来说我们只会得到一个 (string | number | null)[] 的数组,在需求上来说我们并不需要 null 在我们的类型之中,但是由于 TypeScript 对于函数内部的类型守护并不会主动去推断为传入的函数 item => typeof item === 'string' 为一个 is 形式的类型守护函数。所以在这里我们必须主动声明我们的 is 行为,比如说这样:

ts 复制代码
const onlyStrings = ['1', null, '2', 3]
  .filter((item): item is string => typeof item === 'string')

Playground

这看起来一点也不美妙,我把一件重复的事情做了俩遍。明明我知道 item 是 string 了,明明 ts 也知道了,但是在这里知道的的状态并没有传递到外面来,只是作为一个内部的类型被无声的消耗了,或许在这里我们可以利用这个信息。

关于类型守护

上面我们聊到了有关于类型守护的使用和问题,这里我们暂时先不去思考 is 这个特性。我们来看看普通的类型守护我们应该如何去使用,接下来我们看一下这段代码。

ts 复制代码
declare const a: unknown
if (typeof a === 'string') {
  a.split
//^? string
}

Playground

我们可以看到当我们使用 typeof a === 'string' 的时候在 if 的作用域中我们的变量 a 已经被守护为了 string 类型,这很常见吧,应该大多数人都这么写过,我们再将上面的过程进行一个变换。

ts 复制代码
function returnStr(a: unknown) {
  if (typeof a === 'string') {
    return a
  }
}
declare const a: unknown
const b = returnStr(a)
//    ^? string | undefined

Playground

我们可以看到我们拿到一个类型守护 typeof a === 'string' 目标所处理的变量 a 的被守护后的类型,然后我们可以拿起我们熟悉的科里化和泛型再对这段代码进行抽象。

工具方法的抽象

ts 复制代码
declare function isWhat<T = never>(
  match: (x: unknown) => T
): (x: unknown) => x is T

上面的代码看起来很简单吧,接下来我们对这段代码进行一个简单的解析和以及对边界情况的优化。

在我们上面关于类型守护 的讨论过程中,我们可以发现:「当我们的函数中有类型守护并且返回了被类型守护目标的时候,我们可以拿到这个目标的被守护后的类型」。然后众所周知我们有一种方法去获取我们函数的返回类型,在这里我们简单的利用一个简单的范型来做一下这件事。

我们可以构造一个函数类型参数的需求,并且为他的返回值的位置设置上范型 T ,这样子我们就以最简单的方式获取到了传入函数的返回值类型。

ts 复制代码
declare function isWhat<T = never>(match: (x: unknown) => T): T

然后我们可以将该函数的返回值构造为一个新的函数,让它返回一个由 is 处理的类型守护函数并且让类型守护的目标为我们在参数中获取到的函数返回值。

typescript 复制代码
declare function isWhat<T = never>(match: (x: unknown) => T): (x: unknown) => x is T

那么我们应该如何去使用呢?

ts 复制代码
const onlyStrings = ['1', 1].filter(
//    ^? (string | number)[]
  isWhat(x => typeof x === 'string' ? x : void 0)
)

Playground

诶呀!怎么不对呢。在写代码的时候遇到小挫折问题不大!我们先来看一下这段代码。

ts 复制代码
declare function isString(a: unknown): a is (string | undefined)
const onlyStrings = ['1', 1].filter(isString)
//    ^? (string | number)[]

Playground

我们可以看到当我们使用 is 的时候如果同时联合了一个 undefined 类型时,会导致我们无法使得它在 filter 的场景下进行使用。经常写 TypeScript 的都知道,类型不干净,亲人俩行泪。你应该也想到了,在这里我们只需要想办法将传入给 isWhat 函数的函数参数返回值的类型中的 undefined 去掉就可以了,去掉的办法有一个,这里我们采用一个最简单的办法。

我们再对我们的 isWhat 函数进行一个简单的变换,将函数参数反向推到的传入的 undefined 类型抛弃掉,我们再来看一下这段代码。

ts 复制代码
declare function isWhat<T = never>(match: (x: unknown) => T | undefined): (x: unknown) => x is T

const onlyStrings = ['1', 1].filter(
//    ^? string[]
  isWhat(x => typeof x === 'string' ? x : void 0)
)

Playground

好耶!😆

到这里我们的基本框架便已经得到了,我们通过一个 isWhat 函数便可以反向利用类型守护得到一个特定目标的 is 函数从而让我们少写俩段废话,不过聪明的读者可能发现了上面的写法实际上是有缺陷的,没办法制造一个 isUndefined 的判定函数,这里我简单介绍俩种解决办法。

ts 复制代码
declare const notMatched: unique symbol
declare function isWhat<T = never>(match: (x: unknown) => T | typeof notMatched): (x: unknown) => x is T

const onlyStrings = ['1', 1].filter(
  isWhat(x => typeof x === 'string' ? x : notMatched)
)

Playground

经常写 TypeScript 的都知道,unique symbol 是个好东西,用的好大家都开心。在这里我们也借用一下它,由于他的唯一性,只要没有人去使用这个具体的 notMatched 作为比较目标就不会出现像 undefined 样的过于常见的问题,这个我们能通过很简单的约定就能达成,不过在这里我们必须暴露给对应的用户这个特殊的 unique symbol ,可能还是不太好,万一有谁滥用了呢?我们再简单的迭代一下。

ts 复制代码
// @filename: isWhat.ts
declare const notMatched: unique symbol
export declare function isWhat<T = never>(match: (x: unknown, notMatched: unique symbol) => T | typeof notMatched): (x: unknown) => x is T
// @filename: foo.ts
import { isWhat } from './isWhat'
const onlyStrings = ['1', 1]
  .filter(isWhat((x, _) => typeof x === 'string' ? x : _))

这样子只要不是特地情况下,就不会发生误用 symbol 的场景了。不过就算到这我们也只是介绍了一种方法,接下来我们讲一个另外特殊的方法。

ts 复制代码
export declare function isWhat<T = never>(match: (x: unknown) => T): (x: unknown) => x is T
const onlyStrings = ['1', 1]
//    ^? string[]
  .filter(isWhat(x => {
    if (typeof x === 'string') return x
    throw void 0
  }))

Playground

经常写 TypeScript 的都知道,throw 是一个特殊的分支。当你触发了这个特殊的分支后,你可以发现所有走到这个分支的类型都好像进入了黑洞一样,不会再派生联合上新的类型。

那么利用它!我们便发现我们不需要引入任何的特殊类型用来作为 fallback,不过代价是 throw 暂时还没有表达式,无法在三元表达式中进行使用。不过如果某个提案如果通过了的话,或许我们就能怎么写了。

ts 复制代码
const onlyStrings = ['1', 1]
  .filter(isWhat(x => typeof x === 'string' ? x : throw void 0))

是不是很酷呢?

感谢

《TypeScript Effective》的作者 danvk 提供了一个 PRInfer type predicates from function bodies using control flow analysis(通过控制流分析从函数体中推断类型谓词) 」,在这里面我获取了一个灵感并基于他的想法设计了该函数,赞美开源精神。

相关

相关推荐
FreeCultureBoy41 分钟前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom1 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom1 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom1 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom2 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom2 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试
LaoZhangAI3 小时前
2025最全GPT-4o图像生成API指南:官方接口配置+15个实用提示词【保姆级教程】
前端
ONE_Gua3 小时前
chromium魔改——CDP(Chrome DevTools Protocol)检测01
前端·后端·爬虫
LaoZhangAI3 小时前
2025最全Cherry Studio使用MCP指南:8种强大工具配置方法与实战案例
前端
咖啡教室3 小时前
前端开发日常工作每日记录笔记(2019至2024合集)
前端·javascript