TypeScript 的类型注解只活在编译期,编译成 JavaScript 之后全部被擦除,运行时不存在类型检查。理解这一点,后面很多「为什么编译过了还是崩」的问题就顺了。本文按基础类型的顺序把声明写法、边界行为和各类型特有的坑过一遍。
一、声明语法与类型推断
// 关键字 变量名: 数据类型 = 具体值;
let isDone: boolean = false;
let count: number = 42;
let message: string = 'Hello TypeScript!';字段名后面的冒号是必须写的,写错位置或漏写都会直接报类型错误,而不是运行时才出问题。
但也不是每处都要标注——TypeScript 会自动推断:
let a = 5; // 推断为 number
let b = 'hello'; // 推断为 string
const c = 'hello'; // 推断为字面量类型 'hello'(const 不会被重新赋值,可以收得更窄)
// 有初始值时,标注通常是多余的
let d: number = 5; // 冗余
// 没有初始值、或者推断结果过宽时才需要标注
let e: number; // 此时必须标注,否则是 any注意 let 和 const 的推断结果不同:let c = 'hello' 得到 string,const c = 'hello' 得到 'hello'。不需要重新赋值的变量一律用 const,能拿到更精确的类型。
二、布尔型(boolean)
let isDone: boolean = false;
let hasPermission: boolean = true;
function isAdmin(): boolean {
return true;
}只有 true 和 false 两个字面量。 0、''、null 这些 JS 里的假值不能赋给 boolean,这是和 JS 隐式转换习惯最容易冲突的地方。
三、数字型(number)
let decimal: number = 6;
let hex: number = 0xf00d; // 十六进制
let binary: number = 0b1010; // 二进制
let octal: number = 0o744; // 八进制
let float: number = 3.14;
let negative: number = -100;
let scientific: number = 1.23e10;
let notANumber: number = NaN;
let infinity: number = Infinity;四种进制的写法类型都是同一个 number——TypeScript(和 JavaScript)没有 int、float、double 的区分,整数和浮点共用一个类型。这一点从 Java / C# 转过来时最容易先入为主。
另外 NaN 和 Infinity 的类型也是 number,所以类型系统拦不住它们,判空得靠 Number.isNaN()。
超出安全整数范围(Number.MAX_SAFE_INTEGER,即 2^53 - 1)时需要 bigint(TypeScript 3.2 起,编译目标需 ES2020 及以上):
let big: bigint = 9007199254740993n;
// big + 1 // ❌ bigint 和 number 不能混合运算,必须写成 big + 1n四、字符串型(string)
let color: string = 'blue';
let message: string = "Hello";
// 模板字符串:支持插值和多行
const fullName: string = 'John Doe';
const age: number = 30;
const info: string = `My name is ${fullName} and I am ${age} years old.`;
const multiLine: string = `This is a
multi-line string
in TypeScript.`;单引号、双引号、反引号都是 string,类型层面没有区别。团队里一般靠 ESLint 统一引号风格。
还有一个用得少但值得认识的原始类型 symbol:
const key: symbol = Symbol('key');
const unique: unique symbol = Symbol('unique'); // 每个 unique symbol 都是独立类型五、空值(null)与未定义(undefined)
let nullableNumber: number | null = 10;
let maybeUndefined: string | undefined;
// 直接声明单个类型也可以,但基本没有意义
let emptyValue: null = null;
let notDefined: undefined = undefined;实际项目里几乎不会单独用 null 或 undefined 作为类型,而是和别的类型组成联合类型(number | null)来表示「可能没有值」。
这里的关键开关是 strictNullChecks(属于 strict 的一部分,新建项目默认开启):
| 行为 | strictNullChecks: true |
strictNullChecks: false |
|---|---|---|
null / undefined 能否赋给其他类型 |
不能,必须显式写进联合类型 | 可以,它们是所有类型的子类型 |
let s: string = null |
编译报错 | 通过 |
| 空值检查的实际效果 | 真正有用 | 形同虚设,等于没开 |
开启后还有一条容易撞上的规则——严格模式下读到未赋值的变量会报错:
let maybeUndefined: string | undefined;
console.log(maybeUndefined); // ❌ 严格模式下:变量在赋值前被使用
maybeUndefined = undefined; // ✅ 显式赋 undefined 之后才可读另外,tsconfig.json 里的 exactOptionalPropertyTypes(4.4+)会进一步区分「属性缺失」与「属性存在但值为 undefined」,写接口类型时值得留意。
选 null 还是 undefined:TypeScript 官方风格指南建议统一用 undefined 表示「没有值」,null 留给确实需要表达「空」语义的对外接口(比如后端返回的 null)。两套混用会让判空逻辑到处分叉。
六、数组(Array)
// 写法一:类型后跟 []
let numbers: number[] = [1, 2, 3];
let strings: string[] = ['Hello', 'World'];
// 写法二:泛型语法,两者完全等价
let moreNumbers: Array<number> = [1, 2, 3];
// 联合类型数组
let mixedArray: (number | string)[] = [1, 'Two', 3, 'Four'];
// 只读数组
let readonlyArray: readonly number[] = [1, 2, 3];
// readonlyArray.push(4); // ❌ 只读数组没有变更方法
// readonlyArray[0] = 9; // ❌
// 多维数组
let matrix: number[][] = [
[1, 2, 3],
[4, 5, 6],
[7, 8, 9]
];
// 元素是对象的数组
let users: { id: number; name: string }[] = [{ id: 1, name: 'Alice' }];两个需要留意的点:
readonly number[]与ReadonlyArray<number>等价,但不能与number[]互相赋值:只读数组不能赋给可变数组(丢了只读保证)。反向赋值是允许的,这也是「函数参数尽量声明为只读数组」的理由。- 下标越界不会报错:
numbers[99]的类型是number,运行时却拿到undefined。这是类型系统在这里的一个已知漏洞,开启noUncheckedIndexedAccess后下标访问的类型会变成number | undefined,逼你判空。
七、元组(Tuple)
元组表示「元素数量和每个位置的类型都已知」的数组:
// 基本元组:长度和每个位置的类型都要匹配
let x: [string, number];
x = ['hello', 10]; // ✅
// x = [10, 'hello']; // ❌ 类型不匹配
// x = ['hello']; // ❌ 长度不匹配
// 声明后再赋值也可以,但类型必须严格对上
let user: [string, number, boolean];
user = ['Alice', 25, true];三个进阶写法:
// 可选元素:TypeScript 3.0 起支持
let optionalTuple: [string, number?];
optionalTuple = ['Hello']; // ✅
optionalTuple = ['Hello', 123]; // ✅
// 剩余元素:3.0 起
let restTuple: [string, ...number[]];
restTuple = ['a']; // ✅
restTuple = ['a', 1, 2, 3]; // ✅
// 具名标签:4.0 起,纯可读性提升,不影响类型
let labeled: [name: string, age?: number];
labeled = ['Alice', 25];
// 只读元组
const readonlyTuple: readonly [string, number] = ['Hello', 123];元组最常见的用途是函数返回多个值,配合解构很自然:
function parseName(input: string): [string, string] {
const [first, ...rest] = input.split(' ');
return [first, rest.join(' ')];
}
const [firstName, lastName] = parseName('John Doe');元组有个反直觉的地方:push、pop 这类方法在元组上仍然可用,而且 push 只检查元素类型、不检查长度:
let pair: [string, number] = ['a', 1];
pair.push(2); // ✅ 编译通过,但运行时长度已经变成 3
// pair.push(true); // ❌ 元素类型不对,这里会报错所以元组适合表达「固定结构的数据」,不适合当可变集合用。真要锁定长度,得加 readonly。
八、枚举(Enum)
enum Color { Red, Green, Blue }
let c: Color = Color.Red;
console.log(Color.Red); // 0
console.log(Color.Green); // 1
console.log(Color.Blue); // 2
console.log(Color[0]); // 'Red' —— 数字枚举支持反向映射数字枚举成员默认从 0 开始自增,也可以显式指定起点或逐个赋值:
enum Status { Pending = 1, Running, Done } // 1, 2, 3
// 字符串枚举:没有自增、也没有反向映射
enum Direction { Up = 'UP', Down = 'DOWN' }
console.log(Direction.Up); // 'UP'
// console.log(Direction['UP']); // ❌ 字符串枚举不支持反向映射关于枚举有三个必须知道的点:
- 它是少数会生成运行时代码的类型特性。写
enum Color { Red },编译产物里会多出一个真实的Color对象;而interface、type、类型注解编译后全部消失。所以枚举不是「零成本」的。 const enum会在编译期把引用处直接内联成字面量、不生成对象,但 babel / esbuild 这类只做单文件转译、不读类型信息的工具无法内联它,需要额外配置(preserveConstEnums等)。这也是很多团队直接禁用const enum的原因。- 枚举的取值是「运行时真实存在的对象属性」,所以不能只当类型用——比如
Color既能做类型也能做值,而interface只能做类型。
想让取值更可控,可以用 as const 对象 + 联合类型替代(这也是现在社区比较推荐的做法):
const Color = {
Red: 'RED',
Green: 'GREEN',
Blue: 'BLUE'
} as const;
// 从对象反推出联合类型:'RED' | 'GREEN' | 'BLUE'
type Color = typeof Color[keyof typeof Color];
function paint(color: Color) { /* ... */ }
paint(Color.Red); // ✅
// paint('RED'); // ✅ 字符串字面量也能通过,因为类型就是它
// paint('PINK'); // ❌这样做没有运行时代码开销,取值就是普通的字符串,和 JSON 交互时也不用额外转换。
九、any、unknown、void、never
这四个容易混在一起,先看它们的赋值关系:
| 类型 | 能赋给它 | 它能赋给 | 能否直接使用 |
|---|---|---|---|
any |
任何类型 | 任何类型 | 可以,不做任何检查 |
unknown |
任何类型 | 只能赋给 unknown 和 any |
不可以,必须先收窄类型 |
never |
只有 never |
任何类型 | 它是「永不存在的值」,本身不可用 |
void |
undefined(严格模式下) |
any、unknown |
只用于函数返回值位置 |
any:任意类型
let anything: any = 'This can be anything!';
anything = 42;
anything = true;
anything.foo.bar(); // 编译通过,运行时才崩
const n: number = anything; // 编译通过any 关闭了该值的全部检查,而且会传染:any 参与运算的结果还是 any,类型检查会沿着调用链一路失效。项目里应该开启 noImplicitAny(属于 strict),至少保证「没写类型」不会静默变成 any。
unknown:未知类型
let value: unknown = 'hello';
// value.toUpperCase(); // ❌ 直接调用成员会报错
if (typeof value === 'string') {
value.toUpperCase(); // ✅ 收窄之后才可用
}
// 或者显式断言
const len: number = (value as string).length;unknown 是 any 的安全版本:能接住任何值,但使用前必须证明它是什么。接外部数据(接口响应、JSON.parse、postMessage)时优先用 unknown,不要用 any。
void:无返回值
function warnUser(): void {
console.log('This is my warning message');
}void 和 undefined 不是一回事:void 表示「调用方不应该使用返回值」。这一点在回调参数位置上有个特别的行为——() => void 可以接受返回任意值的函数:
function run(cb: () => void) { cb(); }
run(() => 123); // ✅ 允许,返回值会被忽略
// function f(): void { return 123; } // ❌ 直接声明返回 void 却返回值,报错这个规则容易被误认为是类型漏洞,其实是有意为之:[1,2,3].forEach(x => arr.push(x)) 能通过,就是因为 forEach 的回调声明为 => void,而 push 返回 number。
never:永不返回
// 抛异常的函数:不会有正常返回
function throwError(message: string): never {
throw new Error(message);
}
// 死循环:永远不结束
function infiniteLoop(): never {
while (true) {}
}never 是最严格的类型:它可以赋值给任何类型,但除 never 之外没有任何类型能赋值给它。正因为如此,它能用来做穷尽性检查——这是它最有价值的用法:
type Shape = 'circle' | 'square';
function area(shape: Shape): number {
switch (shape) {
case 'circle':
return 1;
case 'square':
return 2;
default: {
// 走到这里说明上面的分支没覆盖完
// 如果给 Shape 新增成员却忘了加分支,这一行会编译报错
const exhaustiveCheck: never = shape;
throw new Error('未处理的形状: ' + exhaustiveCheck);
}
}
}这里 shape 在 default 分支已被收窄为 never,所以赋值成立;一旦 Shape 增加新成员而 switch 没跟上,赋值就会失败并提示你补分支。另外,TS 里函数返回值推断为 never 还有一个推论:如果函数某个分支返回 never,那个分支不会影响返回类型的推断。
十、类型断言与类型收窄
declare const someValue: unknown;
// 尖括号语法:在 .tsx 里会和 JSX 冲突,不推荐
let strLength1: number = (<string>someValue).length;
// as 语法:推荐
let strLength2: number = (someValue as string).length;
// 实际场景:DOM 查询的返回类型需要收窄
const canvas = document.getElementById('main_canvas') as HTMLCanvasElement;
// 双重断言:转型跨度太大时,TS 会拦下来,需要中转一次
const value: unknown = 'hello';
const strLength3: number = (value as unknown as string).length;as 不是万能的——只有当两个类型「有足够重叠」时才允许断言,否则会报 Conversion of type ... may be a mistake,必须用 as unknown as X 中转。断言是单方面告诉编译器「相信我」,它不会在运行时做任何转换,写错了照样崩。
所以优先用收窄(narrowing)而不是断言,编译器和运行时行为一致:
function format(input: string | number): string {
if (typeof input === 'number') {
return input.toFixed(2); // 这个分支里 input 已收窄为 number
}
return input.trim(); // 这里收窄为 string
}
// 自定义类型守卫:返回类型写成 obj is User
interface User { id: number; name: string }
function isUser(obj: unknown): obj is User {
return typeof obj === 'object' && obj !== null
&& typeof (obj as User).id === 'number'
&& typeof (obj as User).name === 'string';
}十一、常见坑汇总
- 类型注解编译后全部消失:
let n: number = 'a' as any编译能过,运行时n就是字符串。类型系统只保护编译期。 any会传染:一处any,后面整条调用链的类型检查都可能失效;开noImplicitAny兜底。- 元组不是定长数组:
push不检查长度,运行时长度会变。要锁死就加readonly。 enum不是零成本:会生成运行时代码;const enum在 babel / esbuild 下需要额外配置。readonly T[]与T[]不互相赋值:只读可以接收可变,反之不行。- 数组下标越界不报错:类型是
T,实际可能是undefined,开noUncheckedIndexedAccess才会提示。 null/undefined的行为取决于strictNullChecks:没开的话空值检查基本失效。as不做运行时转换:只是让编译器闭嘴,业务上仍要自己判空、判类型。- 泛型箭头函数在
.tsx里要写<T,>:<T>(x: T) => x会被当成 JSX 标签解析,加个逗号或extends即可。
十二、速查卡
| 类型 | 示例 | 典型用途 |
|---|---|---|
boolean | let ok: boolean = true | 开关、标志位 |
number | let n: number = 0xf00d | 所有数字,无 int / float 之分 |
bigint | let b: bigint = 1n | 超过 2^53-1 的整数 |
string | let s: string = 'a' | 文本,模板字符串也是它 |
symbol | const k = Symbol('k') | 唯一键 |
null / undefined | let v: number | null = null | 用联合类型表示「可能没有值」 |
| 数组 | let a: number[] = [1] / Array<number> | 同类元素集合 |
| 元组 | let t: [string, number?] | 定结构数据、多返回值 |
enum | enum C { Red, Green } | 常量集合(有运行时代价) |
any | let x: any | 尽量不用 |
unknown | let x: unknown | 接外部数据,使用前须收窄 |
void | function f(): void | 无返回值函数 |
never | throw / while(true) | 永不正常返回,穷尽性检查 |
object | let o: object = {} | 非原始类型(注意它不表示「任意对象结构」) |
最后提醒一句:object 表示「任何非原始类型」,它并不描述对象里有哪些属性——写 o.name 会直接报错。要描述结构请用 interface 或 type。
评论区