面试官:你说你会玩TS类型体操,那你能把类型打印出来吗?
注:如果你只想查看如何实现“打印类型”,请跳转到最后一章,如果你觉得有用的话,就给我一个免费的赞吧
面试官:听说你上次写了一个 TypeScript Lodash 库,但是好像没什么用吧
我:对,上次我写 TypeScript Lodash 库 还是在上次
面试官:???
我:其实里面的大部分都很难用到,但是它能提升我们对 TS 用法的认知程度,特定场景下也很有用,比如对对象类型的一些操作
面试官:那看起来 TS 类型体操并没有很有用呢?**对初学者来说,经常把一个类型当做一个 变(常)量/值 来使用,于是写出了如下代码,你能通过某些特殊的操作,让这段代码能够成功执行吗?**如果你可以解决这个问题,我就认为你的 TS 知识能够通过我的面试
type A = "111"
console.log(A)
我:这个问题对应的需求场景不常见,我来试试...
在回答这个问题之前,我们需要对 TS 的源码/原理有基本的认识
然后要先回答这三个问题:
- TS 中的类型是什么?
- TS 中的类型为什么不能当做值来使用?
- TS 中是如何处理像“类型不能当一个值来使用”的错误的?
如何阅读 TS 源码?
如何拉取 TS 源代码并编译?
关于 TS 源码的阅读和调试可以参考这篇文章我读 Typescript 源码的秘诀都在这里了
使用 git 拉取 TS 源代码(注意,源代码超过 1 个 G)
git clone https://github.com/microsoft/TypeScript.git
进入生成的文件夹 TypeScript,装包
yarn
编译
yarn run build:compiler
你能用自己编译好的 TS 源码做些什么
VSCode TS 服务
VSCode 中的 TS 语言服务是可以自定义的
如果你当前编辑的是一个 TS 文件,在上图红框所示的位置(看到你 VSCode 的右下角),可以设置 TS 的版本
使用 VS Code 的版本,即使用 VS Code 自己用的版本,一般为最新的稳定版本的 TypeScript 语言服务,不需要额外的安装
如果你在你的项目的 node_modules 中安装了 typescript,那么会像我一样出现另一个选项,使用工作区版本,一般而言,你最好使用工作区的版本,让 VSCode 中的语言服务与你的项目一致,否则,有可能出现代码提示
与编译/类型检查结果
不一致的情况
除了上述两种选项,还有一个隐藏选项,参考 VSCode 文档-Using_newer_TypeScript_versions
你可以在 settings.json 文件中配置 typescript.tsdk
选项
在项目中的 .vscode
文件夹中,新建 settings.json
文件
刚刚我们已经对我们使用 git 拉下来的 TS 源码进行了编译,你会发现生成了一个 built/local
文件夹
那么我们可以把这个设置为 VSCode TS 服务
在上述 settings.json 文件中,增加配置
{
"typescript.tsdk": "./TypeScript/built/local/tscserver.js"
}
这样,你就可以魔改 TS 源码,并应用到你的编辑器啦
选择我们刚刚自定义的工作区版本,然后你会发现 VSCode 自动找到了这个目录下的 tsserver.js 文件,这个文件是干啥的呢?让我们在下文进行讲解
其他
使用它进行 debug,学习源码的执行流程
TS 源码的目录结构和对应文件的用处
-
bin 目录:在一些基于 node 的命令行工具中很常见,在 package.json 中的
bin
属性中设置命令及其指向 bin 目录的路径,即可在终端使用该命令,或者配置与 package.json 的 scripts 属性中;在 TS 源码的 bin 目录中,有tsc
和tsserver
两个文件,我们平时用的tsc ./index.ts
就是执行的tsc
文件 -
built 目录:编译产物
-
doc 目录:包含各种文档,如 TypeScript 语言规范和徽标,此目录的文件是过时的规范的快照,如果要看文档,请去TypeScript Handbook
-
lib 目录:一些声明文件,你平常使用的 HTMLDivElement 类型,就是这里面定义的
-
loc 目录:全名 localize,即国际化,最后会被编译到
built/local/{语言 如zh-cn}/diagnosticMessages.generated.json
文件中,如上述的例子type A = '111'; console.log(A)
中console.log
中 TokenA
处的报错代码是2693
,那么我们在built/local/zh-cn/diagnosticMessages.generated.json
文件中搜索到对应的文案提示为“{0}”仅表示类型,但在此处却作为值使用。
,你可以通过这个文件学习 TS 所有能抛出的错误,一共有 1800 + 条 -
scripts 目录:一些编译(或其他用途)脚本,如上述的 loc 目录中的国际化生成逻辑就在 scripts 目录中编写
-
src 目录:核心源码
TS 编译流程
在互联网中鲜有对 TS 源码分析的文章,但是 TS 官方实际上有对 TS 原理的解释,如果你想了解更多可以阅读TypeScript 代码库内部的术语-Terminology_from inside_the_codebase、TypeScript 编译器内部结构-TypeScript_Compiler_Internals,和本文文末的参考文章
我们使用 TS 的目的就是要让他进行类型检查,通过后,输出为浏览器可直接解释执行的 JS 文件
TS 进行类型分析离不开语法树解析,而要将各种语法解析为一个树,就离不开词法分析
分析之前,你首先得认识这些语法
TS 对语法种类的定义,在 TypeScript/src/compiler/types.ts
文件中,搜索 export const enum SyntaxKind
即可找到
在 TS 中,每一种 Node
,都有属性 kind
,其值为上述的 SyntaxKind
export interface Node extends ReadonlyTextRange {
readonly kind: SyntaxKind // 语法类型(包含 Token 类型,和 Node 类型)
readonly flags: NodeFlags // 节点标记,对节点包含的一些特殊的东西的标记,如节点中含有 this,文件中含有 async 函数
/* @internal */ modifierFlagsCache: ModifierFlags // 修饰符标记,如 public、private、protected、readonly 等
/* @internal */ readonly transformFlags: TransformFlags // 节点包含需要转换语法(如 ES高级语法的转换)、特定转换(装饰器)等的标记
readonly decorators?: NodeArray<Decorator> // 装饰器数组(按文档顺序)
readonly modifiers?: ModifiersArray // 修饰符数组
/* @internal */ id?: NodeId // 节点唯一的id
readonly parent: Node // 父节点
/* @internal */ original?: Node // 修改前的原始节点
/* @internal */ symbol: Symbol // 符号(符号指声明的 Token 的 id) (绑定时初始化)
/* @internal */ locals?: SymbolTable // 关联的局部变量(map 类型,符号表)
/* @internal */ nextContainer?: Node // Next container in declaration order (绑定时初始化)
/* @internal */ localSymbol?: Symbol // 节点声明的符号id (绑定时初始化,只针对导出的节点)
/* @internal */ flowNode?: FlowNode // 控制流 节点 (绑定时初始化)
/* @internal */ emitNode?: EmitNode // Associated EmitNode (initialized by transforms)
/* @internal */ contextualType?: Type // 重载时用的临时上下文
/* @internal */ inferenceContext?: InferenceContext // 推理上下文
}
以 const a = 1
举例,它就包含了 SyntaxKind.VariableStatement
、SyntaxKind.VariableDeclarationList
、SyntaxKind.VariableDeclaration
、SyntaxKind.Identifier
、SyntaxKind.NumberLiteral
五种
但是上面的 Node
和 SyntaxKind
都只是对语法的种类进行了(分)归类,TS 还是不知道 const
是什么含义,a
是什么含义,=
是什么含义,
1
是什么含义
TS 使用 unicode 定义了一组字符编码表 CharacterCodes
(在 TypeScript/src/compiler/types.ts
文件中,搜索 export const enum CharacterCodes
),这样就能判断 a
是一个 Identifier
了(变量名的起始字符规则和可以包含的字符规则是固定的,只需从上述字符编码表中进行匹配就可以确认是否为一个 Identifier
),从而将 “代码字符串” 解析为一个个 SyntaxKind
(Token
)
export const enum CharacterCodes {
// ...
space = 0x0020, // " "
_ = 0x5f,
$ = 0x24,
_0 = 0x30,
_1 = 0x31,
_2 = 0x32,
_3 = 0x33,
_4 = 0x34,
_5 = 0x35,
_6 = 0x36,
_7 = 0x37,
_8 = 0x38,
_9 = 0x39,
a = 0x61,
b = 0x62,
c = 0x63,
d = 0x64,
e = 0x65,
// ...
}
TS 中将 “代码字符串” 解析为 “SyntaxKind” 的行为叫 “扫描”,其使用 scan 函数进行处理(在 TypeScript/src/compiler/scanner.ts
文件中,搜索 function scan(): SyntaxKind {
),其本质为一个 有限自动状态机
那么什么是状态机呢?它表示有限个状态以及在这些状态之间的转移和动作;比如在表格排序按钮中,一共有默认排序、升序、降序,那么在默认排序状态点击时,会变为升序状态;升序状态点击时,会变为降序状态;在降序状态点击时,会变为默认状态
在上述表头排序的例子中:
三种状态是有限的:默认、升、降 每次只能有一种状态 且能根据当前状态以一定的规则装换到下一个状态
回到代码中,以 =
字符举例,发现它,可能是 =>
,可能是赋值符 =
,可能是判断严格相等 ===
的运算符(还有可能是 git 里冲突的标记符号)等
这在 scan 函数中是这样实现的
function scan(): SyntaxKind {
while (true) {
tokenFlags = TokenFlags.None
const ch = codePointAt(text, pos)
switch (ch) {
case CharacterCodes.equals:
// 如果是冲突标记
if (isConflictMarkerTrivia(text, pos)) {
pos = scanConflictMarkerTrivia(text, pos, error)
if (skipTrivia) {
continue
} else {
return (token = SyntaxKind.ConflictMarkerTrivia)
}
}
// 如果下一个还是 =
if (text.charCodeAt(pos + 1) === CharacterCodes.equals) {
// 如果下一个又是 =
if (text.charCodeAt(pos + 2) === CharacterCodes.equals) {
return (pos += 3), (token = SyntaxKind.EqualsEqualsEqualsToken) // 那么它就是 ===
}
return (pos += 2), (token = SyntaxKind.EqualsEqualsToken) // 否则就是只有两个等号 ==
}
// 如果下一个是 >
if (text.charCodeAt(pos + 1) === CharacterCodes.greaterThan) {
return (pos += 2), (token = SyntaxKind.EqualsGreaterThanToken) // 那么就是 =>
}
pos++
return (token = SyntaxKind.EqualsToken) // 上面都没匹配,就是赋值符 =
}
}
}
有了上面 scan 得到的 Token 后,就可以 Token 的类别将源码解析成一个个 Node 并生成一个 AST 了(在 TypeScript/src/compiler/parser.ts
文件中,搜索 function parseSourceFileWorker(languageVersion: ScriptTarget
查看上述逻辑)
我们拿到的 Node 节点就和上面代码中的 interface Node
一样,包含节点的起始、结束位置信息、节点类型、引用关系等
在 Node 中可能存在引用外部变量的情况
如:
const a = 1
console.log(a)
因此在 bind 阶段,会将三种情况的 Token 保存在符号表中
见 TypeScript/src/compiler/binder.ts
文件中的 function bind(node: Node | undefined): void
函数说明
其会将声明节点绑定到 symbol(符号) 规则有三种:
当前容器符号的“导出”表,如模块中的导出 当前容器符号的“成员”表,如对象中的属性 当前容器的“局部变量”表,如函数内的局部变量
如上述代码块,其根节点会保存符号表 locals,即局部变量 a
当上面所有的做完以后,ts 可以进行类型检查
以下面的代码块为例
const b: string = 2
ts 会报错:不能将类型“number”分配给类型“string”
在 Node 节点中,还将保存类型信息,即 type 属性
解析后的 Node 节点为
{
// ...
"kind": 294,
"statements": [
{
// ...
"kind": 229,
"declarationList": {
// ...
"kind": 247,
"declarations": [
{
// ...
"kind": 246,
"name": {
// ...
},
"initializer": {
"pos": 17,
"end": 19,
"flags": 0,
"modifierFlagsCache": 0,
"transformFlags": 0,
"parent": "[Circular ~.statements.0.declarationList.declarations.0]",
"kind": 8,
"text": "2",
"numericLiteralFlags": 0
},
"type": {
"pos": 8,
"end": 15,
"flags": 0,
"modifierFlagsCache": 0,
"transformFlags": 1,
"parent": "[Circular ~.statements.0.declarationList.declarations.0]",
"kind": 146
}
}
]
}
}
]
}
在这种情况下,我们就可以通过 initializer,type 两个属性得出初始值和类型是否能通过类型检查了
当然 TS 进行类型检查的核心函数是 checkSourceElementWorker
,其本质也是通过判断节点的 kind,然后执行不同的 check 函数
switch (kind) {
case SyntaxKind.TypeParameter:
return checkTypeParameter(node as TypeParameterDeclaration)
case SyntaxKind.Parameter:
return checkParameter(node as ParameterDeclaration)
case SyntaxKind.PropertyDeclaration:
return checkPropertyDeclaration(node as PropertyDeclaration)
case SyntaxKind.PropertySignature:
return checkPropertySignature(node as PropertySignature)
// ...
}
如校验元组中,每个成员必须都要具名都都不具名
function checkTupleType(node: TupleTypeNode) {
const elementTypes = node.elements
let seenOptionalElement = false
let seenRestElement = false
const hasNamedElement = some(elementTypes, isNamedTupleMember)
for (const e of elementTypes) {
if (e.kind !== SyntaxKind.NamedTupleMember && hasNamedElement) {
grammarErrorOnNode(
e,
Diagnostics.Tuple_members_must_all_have_names_or_all_not_have_names
)
break
}
}
}
对上文代码示例 const b: string = 2
,会执行 reportRelationError
函数,将错误信息设置为 Diagnostics.Type_0_is_not_assignable_to_type_1
function reportRelationError(
message: DiagnosticMessage | undefined,
source: Type,
target: Type
) {
if (!message) {
if (relation === comparableRelation) {
message = Diagnostics.Type_0_is_not_comparable_to_type_1
} else if (sourceType === targetType) {
message =
Diagnostics.Type_0_is_not_assignable_to_type_1_Two_different_types_with_this_name_exist_but_they_are_unrelated
} else if (
exactOptionalPropertyTypes &&
getExactOptionalUnassignableProperties(source, target).length
) {
message =
Diagnostics.Type_0_is_not_assignable_to_type_1_with_exactOptionalPropertyTypes_Colon_true_Consider_adding_undefined_to_the_types_of_the_target_s_properties
} else {
if (
source.flags & TypeFlags.StringLiteral &&
target.flags & TypeFlags.Union
) {
const suggestedType = getSuggestedTypeForNonexistentStringLiteralType(
source as StringLiteralType,
target as UnionType
)
if (suggestedType) {
reportError(
Diagnostics.Type_0_is_not_assignable_to_type_1_Did_you_mean_2,
generalizedSourceType,
targetType,
typeToString(suggestedType)
)
return
}
}
// 这里这里
message = Diagnostics.Type_0_is_not_assignable_to_type_1
}
}
}
因此,如果要快速找到 TS 中的某条报错信息对应的底层逻辑,可以到源码中搜索 Diagnostics
的属性
总结出 TS 的编译流程,我们可以回答上文的三个问题
TS 源代码经过词法扫描,生成Token标记,通过语法分析,生成语法树,再通过语义分析,生成符号表,进行类型检查等,最后将源代码编译为 JS 语法
TS 中的类型是什么?
在扫描阶段,TS 中的类型是一个个 Token,如 const a: string = '1'
中的 string
执行 scan 函数时,扫描到 string 时,会认为它是一个 Identifier,然后调用 getIdentifierToken
函数,符合 textToKeyword 中的一个关键字 string
,标记为 150
根据解析到的 token,将 : string
解析为一个 TypeNode
,如上图所示,并挂到最后生成的节点上
type 属性其实是一个对象,因此,你可以理解为类型的本质是使用对象来描述的概念
TS 中的类型为什么不能当做值来使用?
我们知道,TS 最终通过会生成浏览器可执行的 JS 代码,在这期间会去掉类型的声明、使用和断言等,如果类型可以当值使用了,那么就会出现这样的情况
interface A {}
const a = A
const b = 1
TS 编译后,会变成
const a =
const b = 1
这显然不符合 JS 语法规范,1 是 const 声明的常量必须赋初始值,2 是赋值符右边不能没有东西
TS 中是如何处理像“类型不能当一个值来使用”的错误的?
根据上文的分析,在 bind 阶段,会将三种东西放到符号表中
当前容器符号的“导出”表,如模块中的导出
当前容器符号的“成员”表,如对象中的属性
当前容器的“局部变量”表,如函数内的局部变量
符号表中还包含了我们声明的类型
假设类型做为值时,根据 JS 读取变量的规则,会到作用域链上依次查找,也就是每个作用域下的局部(全局)变量,如果找到的是一个类型,自然就能抛出错误
再根据上文提到的各种 Diagnostics
,得到报错的信息
把类型打印出来
我们想得到的类型长什么样呢?把上文提到的 TypeNode 打印出来吗?那应该没有很多人看的懂吧
我们能否把类型转换为字符串呢?答案是可以的
TS compiler API 提供了把 TS 文件,转换为 JS 文件的方法,参考文档,一个简单的变换函数_ts.transpileModule
在 ts.transpileModule
中,可以添加 transformers
,即自定义的转换器
ts.transpileModule(yourFileData /* 你读取的 ts 文件的结果 */, {
transformers: { before: [xxx /* 自定义的转换器 */] },
})
在自定义转换器中,我们可以把能找到类型声明的标识符替换一下,得到其对应的类型字符串
首先创建两个文件
type-print.mjs
、test.ts
要提取类型,并将其转换为类型字符串,那么需要调用 ts.createProgram
生成一个 program,然后调用 program.getTypeChecker
获得 typeChecker
type-print.mjs
import ts from "typescript"
import fs from "fs"
const program = ts.createProgram(["./test.ts"], { emitDeclarationOnly: false })
const result = ts.transpileModule(fs.readFileSync("./test.ts").toString(), {
transformers: { before: [createTransformer(program.getTypeChecker())] },
})
接下来实现 createTransformer
函数
/**
* 创建一个 transformer
* @param {ts.TypeChecker} typeChecker
*/
const createTransformer = (typeChecker) => {
return (context) => {
return (node) => ts.visitNode(node, visit /* 我们需要实现这个函数 */)
}
}
在上述函数中,transform 会遍历所有节点,然后可以对节点进行一些替换操作,我们需要实现 visit 函数
/**
* node 遍历
* @param {ts.Node} node
*/
function visit(node) {
// 如果 node 的类型 (SyntaxKind) 是 标识符 (Identifier),并且有 id,那么就认为是把类型当做值来使用
// 找到节点的父节点,并依次向父级查找,依次查找,直到找到含有 locals 属性且该映射表中含有当前标识符的节点
// 用 typeChecker.getTypeAtLocation 得到 type
// 然后在不同的 type 中,执行不同的生成类型字符串的逻辑
// 其最生成逻辑是调用 typeChecker.typeToString,并将结果传递给 ts.factory.createStringLiteral 函数,将节点替换为 StringLiteral
}
visit 函数的逻辑我们梳理了一遍,接下来就是实现部分了
我们首先假设我们要打印的类型很简单
test.ts
type A = 1
console.log(A)
在 ts 的 Type 类型中,有 flags 用于标识类型的种类,如这里的 A 就是数字字面量类型,那么其种类就为 ts.TypeFlag.NumberLiteral
当然,在判断类型之前,首先要拿到类型
if (node.kind === ts.SyntaxKind.Identifier && node.id) {
// 父节点
let parent = node.parent
// 类型的符号
let symbol
// 符号标记,即能不能找到对应的声明
let symbolFlag
// 要被打印的类型的字符串初始值,未知类型打印 error
let typeStr = "error"
const updateTypeStr = (str) => {
if (typeStr === "error") typeStr = str
}
/*
* 查找父节点的 locals,拿到类型的符号
*/
while (
!(
parent &&
parent.locals instanceof Map &&
parent.locals.size > 0 &&
symbolFlag
) &&
parent
) {
const locals = parent.locals
symbolFlag = !!parent.locals && (symbol = locals.get(node.escapedText))
parent = parent.parent
}
if (!symbolFlag) return ts.visitEachChild(node, visit, context)
// 通过 typeChecker.getTypeAtLocation 函数,这里的 type 就是我们拿到的类型
const type = typeChecker.getTypeAtLocation(symbol.declarations[0].name)
}
通过 type.flags
即可判断类型的种类,由于 ts 内部是通过二进制(表现仍为10进制)来存储类型种类的,因此需要用逻辑与 &
来判断是否包含此类型
if (type.flags & ts.TypeFlag.NumberLiteral) {
updateTypeStr(typeChecker.typeToString(type))
}
return ts.factory.createStringLiteral(typeStr)
这样就可以得到结果 result
最后生成文件并执行,即可打印出 1
fs.writeFileSync("./index.js", result.outputText)
生成文件结果
index.js
console.log("2");
node ./index.js
# 1
当然除了数字是字面量类型,还有很多其他字面量类型,这里可以直接用 ts.TypeFlag.Literal
来判断
除了简单类型,还有如 interface
(接口),联合类型 (string | number
)、相交类型({ a: 1 } & { b: 2 }
)之类的复杂类型
我们都可以像上面一样写判断一一处理
完整代码如下:
type-print.mjs
import ts from "typescript"
import fs from "fs"
import path from "path"
/**
*
* @param {ts.TypeChecker} typeChecker
* @returns
*/
const createTransformer = (typeChecker) => {
return (context) => {
return (node) => ts.visitNode(node, visit)
/**
*
* @param {ts.Node} node
* @returns
*/
function visit(node) {
if (node.kind === ts.SyntaxKind.Identifier && node.id) {
let parent = node.parent
let symbol
let symbolFlag
let typeStr = "error"
const updateTypeStr = (str) => {
if (typeStr === "error") typeStr = str
}
while (
!(
parent &&
parent.locals instanceof Map &&
parent.locals.size > 0 &&
symbolFlag
) &&
parent
) {
const locals = parent.locals
symbolFlag =
!!parent.locals && (symbol = locals.get(node.escapedText))
parent = parent.parent
}
if (!symbolFlag) return ts.visitEachChild(node, visit, context)
const type = typeChecker.getTypeAtLocation(symbol.declarations[0].name)
if (type.flags & ts.TypeFlags.Object) {
if (
type.flags & ts.TypeFlags.Enum ||
type.flags & ts.TypeFlags.EnumLike ||
type.flags & ts.TypeFlags.EnumLiteral
) {
return ts.factory.createIdentifier(typeChecker.typeToString(type))
}
const fields = []
const properties = type.getProperties()
for (const prop of properties) {
const name = prop.getName()
const propType = typeChecker.getTypeOfSymbolAtLocation(prop, node)
const propTypeName = typeChecker.typeToString(propType)
const hasQuestionToken = prop.valueDeclaration ? !!prop.valueDeclaration.questionToken : false
fields.push(
`${name}${hasQuestionToken ? "?" : ""}: ${propTypeName};`
)
}
updateTypeStr(fields.length > 0 ? `{\n\t${fields.join("\n ")}\n}` : '{}')
} else if (type.flags & ts.TypeFlags.UnionOrIntersection) {
const fields = []
for (const prop of type.types) {
fields.push(typeChecker.typeToString(prop))
}
let separator = ''
if (type.flags & ts.TypeFlags.Union) {
separator = '|'
} else if (type.flags & ts.TypeFlags.Intersection) {
separator = '&'
}
updateTypeStr(fields.join(` ${separator} `))
} else if (type.flags & ts.TypeFlags.Literal) {
updateTypeStr(typeChecker.typeToString(type))
}
return ts.factory.createStringLiteral(typeStr)
}
// 其它节点保持不变
return ts.visitEachChild(node, visit, context)
}
}
}
function resolvePath(inputPath) {
return path.resolve(process.cwd(), inputPath)
}
const program = ts.createProgram([resolvePath('./test.ts')], { emitDeclarationOnly: false })
const result = ts.transpileModule(fs.readFileSync(resolvePath('./test.ts')).toString(), {
transformers: { before: [createTransformer(program.getTypeChecker())] },
})
fs.writeFileSync("./index.js", result.outputText)
console.log("编译成功")
最后结果
你可以在 github 中获取这段代码 github-ts-type-print,本文原文请戳这里
参考文章(力荐):
博客园-TypeScript源码详细解读(2)词法1-字符处理
博客园-TypeScript源码详细解读(3)词法2-标记解析
博客园-TypeScript 源码详细解读(4)语法1-语法树
博客园-给萌新的TS_custom_transformer_plugin教程——TypeScript 自定义转换器插件
转载自:https://juejin.cn/post/7108496585071263781