阶段二 · 类型系统基础

变量声明, 基础类型与类型注解

前置回顾

  • 第 01 篇装好了 Node.js + pnpm + TypeScript 工具链
  • 用 pnpm exec tsc 编译, pnpm exec tsx 直接运行 .ts 文件
  • 本篇开始写真正的 TS 代码, 从变量声明和类型系统入手

一句话总结

TS 用 const/let 声明变量(不用 var), 类型标注写在变量名后面(name: type), 大多数情况编译器能自动推断类型不需要手写.

变量声明: const, let, var

TS 有三种变量声明关键字. 实际只用前两个:

const port = 3000;       // 不可重新赋值
let count = 0; // 可重新赋值
var legacy = "别用这个"; // 历史遗留, 现代代码不用

const 优先原则

和 Rust 的 let 默认不可变一样, TS 的最佳实践是默认用 const, 需要改值时才用 let.

const name = "Alice";
name = "Bob"; // 编译错误: Cannot assign to 'name' because it is a constant

let age = 25;
age = 26; // 正常
语言 不可变 可变
Rust let x = 1 let mut x = 1
Go const x = 1(仅编译期常量) x := 1 / var x = 1
TypeScript const x = 1 let x = 1

const 的 '不可变' 是浅层的

const 只阻止重新赋值(rebinding), 不阻止修改对象内部属性:

const user = { name: "Alice", age: 25 };
user.age = 26; // 合法, 修改属性
user = { name: "Bob" }; // 编译错误, 不能重新赋值

这和 Rust 不同. Rust 的 let 不可变是深层的, 结构体内部字段也不能改. TS 要实现深层不可变需要 as const 或 Readonly<T>(后续篇章讲).

let vs var: 作用域差异

let 是块作用域(和 Go/Rust 一样), var 是函数作用域:

function example() {
if (true) {
let a = 1; // 只在 if 块内可见
var b = 2; // 整个函数内可见
}
// console.log(a); // 编译错误: a is not defined
console.log(b); // 输出 2, var 泄漏到了 if 外面
}

let = 房间门卡, 只能进声明所在的那间房.
var = 楼层通行证, 声明在哪间房都无所谓, 整层楼都能用.

变量提升(Hoisting)

var 的另一个坑: 声明会被"提升"到函数顶部, 但赋值不会:

function broken() {
console.log(x); // 输出 undefined(不报错, 因为声明被提升了)
var x = 10;
console.log(x); // 输出 10
}

function correct() {
// console.log(y); // 编译错误: Block-scoped variable 'y' used before declaration
let y = 10;
console.log(y);
}

在 Go 和 Rust 中, 使用未声明的变量直接编译失败. var 的提升行为是 JS 历史包袱, 容易导致隐蔽 bug. 这就是为什么 TS 代码永远不用 var.

规则: const > let > var

  1. 默认用 const
  2. 确实需要修改值时用 let
  3. 永远不用 var

开启 strict 模式后, ESLint 的 no-var 规则会自动禁止 var.

基础类型

number

TS 的 number 统一表示所有数字, 不区分整数和浮点:

const integer = 42;        // 整数
const float = 3.14; // 浮点
const hex = 0xff; // 十六进制
const binary = 0b1010; // 二进制
const octal = 0o777; // 八进制
const negative = -100;
语言 整数 浮点
Go int, int8, int16, int32, int64 float32, float64
Rust i8 ~ i128, u8 ~ u128 f32, f64
TypeScript 都是 number 都是 number

底层是 IEEE 754 双精度浮点(64 位), 安全整数范围: -(2^53 - 1) 到 2^53 - 1. 超出范围用 bigint.

string

const greeting = "hello";        // 双引号
const name = 'Alice'; // 单引号(效果相同)
const message = `Hi, ${name}`; // 模板字符串, 支持插值

// 多行字符串
const multiline = `
第一行
第二行
`;

模板字符串(反引号)类似 Go 的 fmt.Sprintf("Hi, %s", name) 或 Rust 的 format!("Hi, {name}"), 但语法更简洁, 直接在字符串内用 ${} 嵌入表达式.

boolean

const isReady: boolean = true;
const isEmpty = false; // 类型推断为 boolean

null 和 undefined

TS 有两个表示"空"的值, 这是 JS 历史遗留:

值 含义 对标
undefined 变量已声明但未赋值, 或函数无返回值 Go 的零值概念
null 显式表示"没有值" Go 的 nil / Rust 的 None
let x: number | undefined;  // 声明但未赋值
console.log(x); // undefined

const user = null; // 显式表示没有用户

strictNullChecks

开启 strict 后, null 和 undefined 不能随意赋给其他类型:

let name: string = null; // 编译错误
let name: string | null = null; // 正确, 显式允许 null

这类似 Rust 的 Option<T>: 要表示"可能为空", 必须在类型上显式声明.

bigint

超出 number 安全范围时使用:

const big = 9007199254740993n; // 字面量加 n 后缀
const also = BigInt("9007199254740993");

symbol

用于创建唯一标识符, 主要在框架底层使用, 日常业务代码很少直接用:

const id = Symbol("userId");
const anotherId = Symbol("userId");
console.log(id === anotherId); // false, 每个 Symbol 都是唯一的

类型注解

类型标注写在变量名后面, 用冒号分隔:

let count: number = 0;
let name: string = "Alice";
let active: boolean = true;

类型推断

大多数时候不需要手写类型, 编译器能从赋值推断:

let count = 0;          // 推断为 number
let name = "Alice"; // 推断为 string
const port = 3000; // 推断为字面量类型 3000(const 更精确)

这和 Go 的 := 是一个思路:

count := 0       // Go 推断为 int
name := "Alice" // Go 推断为 string

何时必须显式注解

三种情况需要手写类型:

// 1. 声明时不赋值
let result: number;
result = calculate();

// 2. 函数参数(永远需要)
function add(a: number, b: number) {
return a + b;
}

// 3. 推断结果不符合预期
let ids: number[] = []; // 空数组推断为 never[], 必须注解

推荐: 能省则省

不要写 let x: number = 1 这种冗余注解. 赋值已经告诉编译器类型了.
只在推断不出或推断不准确时才写. 这和 Rust 社区的习惯一致.

数组与元组

数组

两种等价写法:

const numbers: number[] = [1, 2, 3];
const names: Array<string> = ["Alice", "Bob"];

// 推断: 直接赋值时通常不需要注解
const scores = [100, 95, 88]; // 推断为 number[]

社区惯例用 number[] 写法, Array<number> 用于泛型嵌套时可读性更好(如 Array<[string, number]>).

元组

固定长度, 每个位置类型可以不同:

const point: [number, number] = [10, 20];
const entry: [string, number] = ["age", 25];

// 解构
const [x, y] = point;

对比 Rust 的元组:

let point: (f64, f64) = (10.0, 20.0);
let entry: (&str, i32) = ("age", 25);

语法几乎对称: Rust 用圆括号 (), TS 用方括号 [].

元组的越界访问

TS 会检查元组的索引越界:

const pair: [string, number] = ["hello", 42];
pair[0]; // string
pair[1]; // number
pair[2]; // 编译错误: Tuple type '[string, number]' of length '2' has no element at index '2'

特殊类型

any: 逃生舱

any 关闭该变量的所有类型检查, 等于回到 JS 模式:

let data: any = "hello";
data = 42; // 合法
data = true; // 合法
data.foo.bar; // 合法(运行时可能崩)

unknown: 安全版 any

unknown 也能接受任何值, 但使用前必须先收窄类型:

let input: unknown = "hello";

// input.toUpperCase(); // 编译错误: 'input' is of type 'unknown'

// 必须先检查类型
if (typeof input === "string") {
input.toUpperCase(); // 正常, 此处 input 已收窄为 string
}
对比 any unknown
赋值给它 任何值都行 任何值都行
从它读取 随便用, 不检查 必须先收窄类型
类比 Go 的 interface{} 不加断言直接用 Go 的 interface{} 必须 type assert 后用
安全性 危险 安全

规则: 需要 any 时先考虑 unknown

外部输入(API 返回值, JSON 解析结果)用 unknown, 然后通过类型守卫收窄.
any 只用于快速原型或者和无类型的第三方库交互, 是最后手段.

void 和 never

// void: 函数不返回有意义的值(类似 Go 无返回值的函数)
function log(msg: string): void {
console.log(msg);
}

// never: 函数永远不会正常返回(抛异常或死循环)
function throwError(msg: string): never {
throw new Error(msg);
}

function infinite(): never {
while (true) {}
}

never 在 Rust 中对应 !(never type), 在 Go 中没有直接对应, 因为 Go 的 panic 不体现在函数签名上.

类型断言

当我们比编译器更清楚一个值的类型时, 用 as 手动指定:

const input: unknown = "hello world";
const length = (input as string).length; // 告诉编译器: 我确定这是 string

对比其他语言:

// Go 的类型断言
length := input.(string)
// Rust 的 as(仅用于数值转换)
let x = 65u8 as char;

什么时候用, 什么时候不该用

// 合理: DOM API 返回的类型不够精确
const el = document.getElementById("app") as HTMLDivElement;

// 合理: 处理 JSON 解析结果
const config = JSON.parse(raw) as AppConfig;

// 不该用: 强行绕过类型检查, 掩盖真实错误
const x = someNumber as unknown as string; // 双重断言, 代码坏味道

类型断言不是类型转换

as 不做任何运行时转换, 它只是告诉编译器"信我". 如果断言错误, 运行时照样崩:

const x: unknown = 42;
const s = x as string;
s.toUpperCase(); // 运行时: TypeError: s.toUpperCase is not a function

这和 Go 的 x.(string) 不同, Go 在运行时会 panic 或返回 ok=false. TS 的 as 零运行时开销, 也零运行时保护.

字面量类型与 const 断言

字面量类型

变量的类型可以是一个具体的值, 而不只是 string 或 number:

let direction: "up" | "down" | "left" | "right";
direction = "up"; // 合法
direction = "north"; // 编译错误: Type '"north"' is not assignable

let httpStatus: 200 | 404 | 500;
httpStatus = 200; // 合法
httpStatus = 201; // 编译错误

这是 TS 类型系统的独特能力, Go 和 Rust 中没有直接对应(Rust 有 const generics, 但用途不同).

const 断言

const 声明的变量, 编译器自动推断为字面量类型:

const port = 3000;   // 类型是 3000, 不是 number
let port2 = 3000; // 类型是 number(因为 let 允许重新赋值)

对于对象和数组, 用 as const 把所有字段递归锁定为字面量类型:

// 没有 as const: 类型是 { method: string; url: string }
const request = { method: "GET", url: "/api" };

// 有 as const: 类型是 { readonly method: "GET"; readonly url: "/api" }
const request2 = { method: "GET", url: "/api" } as const;

request.method = "POST"; // 合法(method 是 string)
request2.method = "POST"; // 编译错误(method 是 readonly "GET")

as const 的典型用途

定义枚举值集合或配置常量时, as const 让类型系统知道精确的值:

const COLORS = ["red", "green", "blue"] as const;
type Color = typeof COLORS[number]; // "red" | "green" | "blue"

比 enum 更轻量, 也是社区更推荐的做法.

快速回顾

  • const/let: 默认 const, 需要改值用 let, 永远不用 var
  • 基础类型: number(不分整数浮点), string, boolean, null, undefined
  • 类型注解: 写在变量名后 name: type, 能推断时省略
  • 数组/元组: number[] 是数组, [string, number] 是定长元组
  • any vs unknown: any 放弃检查, unknown 强制收窄后才能用
  • 类型断言: as Type 告诉编译器"我确定", 但没有运行时保护
  • 字面量类型: 类型可以是具体的值, as const 把对象所有字段锁为只读字面量

动手练习

  1. const vs let: 声明 5 个变量, 判断哪些该用 const, 哪些该用 let, 解释理由.
  2. 类型推断: 写 const x = [1, "hello", true], 把鼠标悬停在 x 上看 IDE 推断出什么类型. 然后改成元组类型标注.
  3. unknown 练习: 写一个函数接收 unknown 参数, 用 typeof 判断后分别处理 string/number/其他情况.
  4. 断言对比: 分别用类型断言和类型守卫两种方式处理一个 unknown 值, 体会安全性差异.
  5. as const: 定义一个 HTTP 方法常量数组 ["GET", "POST", "PUT", "DELETE"] as const, 然后用 typeof 提取出联合类型.
  6. 找 bug: 下面的代码有什么问题? const config = { port: 3000, host: "localhost" }; config.port = "8080";