TypeScript 中 type 和 value 为什么是两个世界?

TypeScript1周前发布 admin
124 0

TypeScript 的时候,你大概率遇到过这两条报错:

'Foo' only refers to a type, but is being used as a value here.
'foo' refers to a value, but is being used as a type here.

翻译过来就是:”你把类型当值用了” 和 “你把值当类型用了”。第一次看到这种报错,很多人是懵的——不都是同一个名字吗,为什么还分”能不能这么用”?

答案是:在 TypeScript 里,类型和值其实活在两个完全独立的平行世界里,只是它们经常共用同一个名字,看起来像是一回事。今天就把这两个世界拆开,看看它们到底是怎么分工、又是怎么互相”串门”的。

一、两张互相独立的名字表

TypeScript 编译器在处理代码的时候,脑子里其实维护着两张名字登记表:一张叫”类型空间”,一张叫”值空间”。

  • 类型空间里登记的是类型:interfacetype 别名、泛型参数……这些东西只存在于编译阶段,帮你在写代码时做检查,跑起来的时候压根不存在。
  • 值空间里登记的是值:constletfunction……这些是实实在在会被编译成 JS、在运行时真正存在的东西。

关键的地方来了:这两张表是互相独立的。同一个名字,可以只出现在其中一张表里,也可以两张表里都有——它们互不干扰,甚至可以同名却代表完全不同的东西。

二、只活在类型世界里的声明

interfacetype 别名,是最典型的”纯类型世界居民”:

typescript:
interface User {
  name: string;
  age: number;
}

type ID = string | number;

这两行代码编译成 JS 之后,什么都不会留下——一行代码都不会生成。UserID 只是编译阶段用来做检查的”说明书”,运行时的 JS 引擎根本不知道它们存在过。这也是 TypeScript 常被称为”JS 的一层类型注解”的原因:类型信息在编译完成后会被彻底擦除,一丁点运行时痕迹都不留。

三、只活在值世界里的声明

反过来,constletfunction 声明的是实打实的运行时值:

typescript:
const count = 5;
function greet(name: string) {
  return `Hello, ${name}`;
}

这里有个容易被忽略的细节:虽然 count 有一个推断出来的类型(number),但这个类型并没有被注册到类型空间里,供你用 count 这个名字直接当类型使用。也就是说,你不能这样写:

typescript:
let x: count; // ❌ 报错:"count" refers to a value, but is being used as a type here.

count 这个名字,从头到尾只存在于值空间。它有类型,但它的名字没有出现在类型表里。这正是很多人困惑的起点:值有类型,不代表值的名字能直接拿去当类型用。

四、两个世界都占了一个坑的声明:class 和 enum

class 是最能体现”两个世界”这个概念的语法:

typescript:
class Person {
  constructor(public name: string) {}
}

const p: Person = new Person("小明");        // ① Person 当类型用
const Ctor: typeof Person = Person;          // ② Person 当值用

同一个标识符 Person,在类型空间里注册的是”一个 Person 实例长什么样”(有 name 属性),在值空间里注册的是”构造函数本身”(new 出实例用的那个函数)。这两份登记互不相关,只是恰好用了同一个名字——就像小区里一栋楼既是”1号楼”(地址),又挂着”1号楼超市”的招牌(商铺名),两者共享同一个门牌号,但指的是完全不同的东西。

enum 也是双料选手,它既是类型,编译后也会变成一个真实存在的 JS 对象:

typescript:
enum Direction {
  Up,
  Down,
}

编译之后,Direction 会变成一个真正的运行时对象(一个正反双向映射表),同时 Direction 这个名字也能当类型用,表示”上或下”这两个取值之一。

五、同一个关键字,在两个世界里意思完全不同:typeof

如果说前面的例子还只是”同名不同表”,那 typeof 关键字则更直接地展示了两个世界的割裂——同一个单词,出现在不同位置,含义完全不同

JS 里原本就有一个 typeof 运算符,它是运行时行为,返回一个描述数据类型的字符串:

typescript:
typeof "hello"; // "string",这是一段真正会执行的代码

而 TypeScript 又给 typeof 加了一层类型世界的用法,叫”类型查询”,作用完全不同:它不产生任何运行时代码,只是在编译阶段”读取”某个值当前的类型:

typescript:
const user = { name: "小明", age: 18 };
type User = typeof user; // { name: string; age: number }

这行 type User = typeof user 不会在编译产物里留下任何 JS 代码,它纯粹是编译器内部的类型推导。同一个 typeof,一个活在值世界(真的会跑),一个活在类型世界(只是编译期的查询),这恰恰证明了两个世界是真的彼此独立,独立到连关键字都可以”撞名”而不冲突。

六、keyof:只对类型世界生效的操作符

keyof 是另一个典型的”纯类型世界”操作符:

typescript:
interface Point {
  x: number;
  y: number;
}

type Keys = keyof Point; // "x" | "y"

注意,这里根本不需要一个真实存在的 Point 对象,keyof 操作的对象是 Point 这个类型的形状描述本身,跟运行时有没有具体的值毫无关系。

这也解释了为什么 keyof 只能写在类型的位置,不能写在值的位置——它天生就是类型世界的居民,从没打算跨界。

七、为什么非要设计成两个世界?

这不是 TypeScript 为了炫技故意搞复杂,而是服务于它最核心的设计目标之一:类型擦除(Type Erasure),编译后不增加任何运行时开销

TypeScript 的承诺是:它只是给 JS 加了一层编译期检查,编译完之后跟你直接手写的 JS 几乎没有区别。要兑现这个承诺,类型层的所有信息就必须能在编译阶段被完整、干净地删掉,不留下任何运行时代码。

如果类型和值不分开,允许类型像值一样在运行时被访问、被计算,那类型信息就没法被安全地整体擦除——编译器得帮你在 JS 里生成一堆原本不需要的运行时结构,来支撑”类型也能在运行时用”这件事。

这既违背了”零运行时开销”的设计初衷,也会让整个类型系统变得臃肿又复杂。

enum 恰恰是这条原则的一个例外:它需要在运行时也存在(比如你想在代码里遍历所有枚举值),所以编译后会保留一个真实对象。这也是为什么社区里对 enum 的评价一直有争议——它主动打破了”类型只活在编译期”的干净设计。

如果你既想要类似枚举的写法,又不想牺牲零开销,可以用 const enum,它会在编译时被直接内联成字面量,不产生独立的运行时对象,更贴近”纯类型世界”的哲学,代价是牺牲了一部分灵活性(比如没法做跨模块的动态引用)。

八、回头看那两条报错

理解了这套”两个世界”的模型,开头那两条报错就不再玄乎了:

  • 'Foo' only refers to a type, but is being used as a value here. —— 你在值该出现的位置,写了一个只登记在类型表里的名字。
  • 'foo' refers to a value, but is being used as a type here. —— 反过来,你在类型该出现的位置,写了一个只登记在值表里的名字。

编译器不是在刁难你,它只是在提醒你:你正在试图从一个世界,直接跨到另一个世界,但这个名字并没有在目标世界里注册过。

小结

声明方式 类型世界 值世界
interface / type 别名
const / let / function
class ✅(实例形状) ✅(构造函数)
enum

以及几个专门用来”跨界”的桥梁操作符:

操作符 作用方向
typeof x(类型位置) 值 → 类型(查询某个值的类型)
keyof T 类型 → 类型(取出属性名组成的联合类型)
T["prop"](索引访问类型) 类型 → 类型(取出某个属性对应的类型)

下次再看到”只是类型不能当值用”或者”typeof 怎么这里能用那里不能用”这种困惑时,不妨先问自己一句:我现在是站在类型世界,还是值世界? 想清楚这一点,很多看起来玄学的报错,其实都有非常朴素的解释。

© 版权声明

相关文章

暂无评论

none
暂无评论...