简介:一份系统讲解TypeScript数据类型的PDF教程,专为刚入门或对类型体系理解不深的开发者设计,帮助理清基础类型、枚举、接口和类之间的联系,减少日常开发中的类型报错。教程内容以数据类型为主线,依次覆盖布尔值、数字、字符串、数组、元组等基础类型,再展开枚举与外部枚举,随后重点分析接口的多种应用方式,包括可选属性、函数类型、数组类型、类类型、接口继承、混合类型、接口继承类以及类静态部分与实例部分的区别,每种用法均配有可直接运行的代码示例,读者可边学边改,快速验证。整份资料为单个PDF文档,压缩包内共1个文件,大小仅1.75MB,便携易用,适合离线阅读或打印。目前已有1099人学习下载,尤其适合需要系统梳理TypeScript类型体系的前端开发者和正在转向工程化JavaScript编程的学习者,既能作为入门教材,也可在项目开发中随手查阅,帮助写出结构更清晰、更健壮的代码。
1. TypeScript 教程怎么读:一份手册型资源的打开方式
这份 TypeScript 课程资源我拆过不止一遍,典型特征是「手册体」:基础数据类型、枚举、接口、类、命名空间、模块、函数、泛型、声明合并、类型推导、类型兼容性,一直到 d.ts 编写和可迭代性,十几个章节按语言特性逐条展开。它不是视频课,也没有按「看完就能写项目」设计进度,而是每个主题给定义、示例、边界说明。适合两类人:刚把 TS 装进项目、每天被类型报错逼疯的开发者;以及写了半年 TS、只会用 any 和 interface 的熟手。前者能照着一章一章补基础,后者可以直接翻到模块、d.ts、类型兼容性这几章对答案。我一般会先通读「类型推导」和「类型兼容性」两章,再回头看基础部分,因为很多报错不是语法问题,是推导和兼容性没想清楚。这份资源正好把这两块放在靠后的位置,逼你先踩坑再看原理。
2. 基础类型与枚举:先分清哪些是类型,哪些是语法糖
2.1 六类基础类型:boolean、number、string、array、tuple 的声明差异
let isDone: boolean = false; let decLiteral: number = 6; let hexLiteral: number = 0xf00d; let binaryLiteral: number = 0b1010; let octalLiteral: number = 0o744;逻辑说明:这段代码把 TS 里最常碰到的几种数值字面量全部摆出来了。boolean 只有 true 和 false 两个值,number 不区分整型和浮点型,这点和很多从 Java 转过来的开发者习惯不同。0xf00d 是十六进制,0b1010 是二进制,0o744 是八进制,TS 的 number 类型一个都不排斥。
参数说明:很多初学者以为 0o 开头的八进制需要额外配置编译器,其实这只是数值字面量,编译目标 target 参数只管语法降级,不管数值解析。真正要注意的是 strictNullChecks 开启后,number 类型不能直接赋 null,否则会报「Type 'null' is not assignable to type 'number'」。
数组的两种写法在项目里经常混用:
let list1: number[] = [1, 2, 3]; let list2: Array<number> = [1, 2, 3];逻辑说明:number[] 和 Array 完全等价,选哪种纯粹是团队风格问题。我一般固定用 number[],因为更短,读起来更接近「这个数组里全是数字」的自然语义。如果你在一个项目里两种都写,格式化工具救不了,code review 时每次都要争论。
参数说明:数组泛型写法 Array 在 JSX 文件里会和标签解析冲突,所以 React 项目里推荐用 number[] 更省事。遇到多维数组,number[][] 比 Array<Array > 清晰得多。
元组是 TS 里容易被低估的类型:
let x: [string, number]; x = ['hello', 10]; // OK x = [10, 'hello']; // Error逻辑说明:元组表示「长度固定、各位置类型不同」的数组。x 的第一个位置必须是 string,第二个位置必须是 number。反过来赋值会在编译期直接报错,这种约束是普通数组给不了的。
参数说明:元组的长度约束只在字面量赋值时生效。如果你声明了 [string, number] 的元组,然后调用 x.push(100),TS 默认不会报错,因为 push 的参数类型是 string | number 的联合类型。这个「能 push 越界元素」的行为是元组最常翻车的地方,资料里没细讲,但实际面试题里反复出现。
2.2 枚举的默认值与外部枚举:declare enum 的适用场景
enum Color {Red, Green, Blue} let c: Color = Color.Green;逻辑说明:不指定成员值时,枚举从 0 开始递增,所以 Color.Red 是 0,Color.Green 是 1。数字枚举在运行时是双向映射的,Color[0] 能取回 'Red',这在调试 switch 分支时很有用。但代价是编译产物里会长出一段反向映射代码,枚举成员越多,冗余越多。
常见做法是:如果枚举值只在代码内部使用,不关心运行时表现,直接写数字枚举。如果枚举要输出给后端或写入数据库,我会给每个成员显式赋值,防止别人在中间插入一个成员导致所有下游值错位。
外部枚举解决的是「JS 里已有常量对象,TS 里想拿到类型形状」的场景:
declare enum Fish { Shark = 1, Tuna }逻辑说明:declare enum 编译时不生成任何代码,它只提供类型信息。适合引用某个全局的 JS 常量对象,不想 import,只想让 TS 在编译期知道你用的值合法。这里的 Shark = 1,Tuna 自动是 2。
参数说明:declare enum 和普通 enum 不要混在同一个文件里,否则生成代码时行为分裂。另外 declare enum 被编译后不存在于运行时,如果你在代码里访问 Fish.Shark,实际运行时会拿不到值。所以这个语法只适合声明外部全局变量,不适合自己项目内部的常量定义。
2.3 空值与关于 let:资料里专门讲 let 的原因
资料里有一小节叫「关于 let」,看似只有一句,其实值得展开。let 声明的变量可以被重新赋值,类型在声明时确定。与之相对,const 声明的变量不能重新赋值,但如果赋的是对象,对象内部的属性还是可以改的。TS 里写类型标注时,let 配合类型收窄非常有用:
let value: number | string = 10; value = 'hello'; console.log(value.length); // OK,此时 value 收窄为 string逻辑说明:value 的静态类型是联合类型 number | string,但赋值 'hello' 之后,在下一行代码里 TS 能推导出 value 是 string,所以访问 .length 不报错。这个「赋值即收窄」的规则是理解后面类型推导章节的基础。
空值 void 一般只用于函数返回值,表示没有返回值。真正容易被忽略的是 strict 参数:严格模式下,undefined 和 null 不能直接赋给 number。所以很多新手照着教程写 let age: number = null,在 tsconfig 开了 strict 后直接报错。这不是教程错了,是教程默认的 tsconfig 参数和你本地的不同。我一般用 void 0 来判断空值,而不是直接到处写 null,避免 strict 模式下不必要的报错。
资料正文里有些「严禁复制」字样的水印,是文档转换时留下的防复制标记,不影响阅读和代码运行。读的时候直接跳过就好。
3. 接口与类:把结构的约束写到代码里
3.1 接口初探:可选属性、函数类型与多余属性检查
interface LabelledValue { label: string; } function printLabel(labelledObj: LabelledValue) { console.log(labelledObj.label); } printLabel({ label: 'TS', size: 10 }); // Error const obj = { label: 'TS', size: 10 }; printLabel(obj); // OK逻辑说明:接口描述的是结构而不是实现,传入对象只要有 label 字段就能用。但这里藏着一个新手最容易迷惑的现象:同一个对象字面量,直接作为参数传会报「对象字面量只能指定已知属性」,先赋值给中间变量再传就不报。这不是编译器抽风,而是 TS 刻意设计的「多余属性检查」——它只针对对象字面量,因为字面量直接传说明开发者想精确匹配,而变量可能是从别处组装来的,类型已经从变量推导过了。
参数说明:如果你明确知道某个对象会传多个字段,但接口里只想暴露一部分,有两个选择:一是把接口字段全部列全,二是让字段可选。可选属性用问号标记:
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig): {color: string; area: number} { let newSquare = {color: "white", area: 100}; if (config.color) { newSquare.color = config.color; } if (config.width) { newSquare.area = config.width * config.width; } return newSquare; }逻辑说明:config.color 和 config.width 都是可选属性,函数内部使用前先判断,避免 undefined 参与运算。if (config.color) 成立后,TS 会自动把 config.color 收窄为 string,后续赋值不会报类型错。
接口还能描述函数形状:
interface SearchFunc { (source: string, subString: string): boolean; }逻辑说明:这种接口声明后可以直接作为函数类型使用,比如 let fn: SearchFunc = function(source, subString) { return source.includes(subString); }。它和 type 定义函数类型相比,最大区别是接口支持声明合并,同名接口会自动合并成员,type 不行。函数重载本质上就是多个函数类型声明的叠加,把重载列表理解成「多个调用签名」,接口里写多个调用签名也能达到类似效果。
3.2 类修饰符的真实边界:public、private、protected
class Animal { public name: string; private age: number; protected kind: string; constructor(name: string, age: number, kind: string) { this.name = name; this.age = age; this.kind = kind; } } class Dog extends Animal { constructor(name: string, age: number) { super(name, age, 'dog'); } getAge() { // console.log(this.age); // Error,子类不能访问 private console.log(this.kind); // OK,protected 允许子类访问 } }逻辑说明:public 是默认修饰符,不写也是 public;private 只能在类内部访问,子类也不行;protected 允许子类访问,但外部实例访问不了。这里的边界对设计类层次结构很重要:银行卡号这类字段用 private,只有自己能用;品牌、型号这类字段用 protected,子类可以读但不能被外部随便改写。
常见误区:TS 的 private 只是编译期约束,编译成 JS 后字段照样可以通过 obj.age 访问。如果你在写库或者处理敏感数据,真正的运行时私有要使用 ECMAScript 的 # 前缀。TS 的 private 更多是给团队协作时看的,让编译器帮你拦住越权访问。
参数属性可以省掉大量模板代码:
class Person { constructor(private name: string, public age: number) {} }逻辑说明:构造函数参数前加修饰符,TS 会自动把这个参数声明为实例属性并赋值,等价于手动写 this.name = name; this.age = age;。参数属性在依赖注入场景里尤其爽,一个构造函数把声明、赋值、访问控制全干了。
参数说明:参数属性里可以叠加 readonly,比如 constructor(public readonly id: string)。加了 readonly 后,这个属性只能在构造函数里赋一次值,其余地方任何赋值都会报错。存取器 getter/setter 也和修饰符配合使用,对外暴露 get 方法、隐藏 set 方法,是封装状态的标准姿势。
3.3 抽象类与把类当接口使用:extends 的边界
abstract class Animal { abstract makeSound(): void; move(): void { console.log('roaming'); } } class Dog extends Animal { makeSound() { console.log('bark'); } }逻辑说明:抽象类可以包含具体方法和抽象方法,抽象方法没有实现,子类必须重写。抽象类不能直接 new,只能被继承。这个设计适合「基类有公共行为、但某个动作必须由子类决定」的场景,比如所有动物都会移动,但叫声各不相同。
另一个容易混淆的写法是把类当接口用:
class Point { x: number = 0; y: number = 0; } interface Point3d extends Point { z: number; }逻辑说明:interface Point3d extends Point 继承的是 Point 的实例类型,不是构造函数类型。也就是说 Point3d 描述的对象必须有 x、y、z 三个字段。很多人以为 extends 一个类就能拿到构造类型,其实想要构造函数类型应该用 typeof Point,两者差得很远。
混入机制也和这里相关:TS 的 mixin 可以理解成「函数参数是类、返回值是类」的组合,再把结果合并到一个目标类上。声明合并章节会讲接口和类的合并规则,混入本质上就是利用这些规则把多个类的行为拼装起来。
4. 模块与命名空间:代码组织的取舍
4.1 导出导入的四种姿势:default、named、export = 与 import *
// config.ts export interface Config { debug: boolean; } export default function loadConfig(path: string): Config { return { debug: true }; }逻辑说明:export default 适合「一个文件只导出一个主对象」的模块。资料里的原话是「如果仅导出单个 class 或 function,使用 export default」,这是官方推荐。但 default 导出的名字由导入方决定,所以团队里最好约定默认导出的命名规则,否则同一个模块在不同文件里被起五个名字,检索起来很痛苦。
命名导出的导入写法:
import { Config } from './config'; import * as configModule from './config';逻辑说明:第一种按名字导入,只把需要的符号拉进来;第二种把整个模块挂到一个变量下,访问时写 configModule.Config。当你要从一个模块里import十几个名字时,用 import * as 一坨挂起来反而清楚,这就是资料里说的「命名空间导入模式」。
副作用导入和旧式模块写法:
import './polyfill'; // 只执行模块,不绑定导出 // 旧式 CommonJS 模块写法 class Validator { } export = Validator;import Validator = require('./Validator');逻辑说明:副作用导入常用于全局样式、polyfill 这类「执行代码就够」的模块。export = 是给 CommonJS 风格模块用的,必须和 import = require() 配对。新项目里我不建议写,但老代码里到处都是。
参数说明:遇到 export = 的文件,tsconfig 里 module 参数要配成 commonjs 才顺畅;如果配成 esnext,这种写法会直接编译报错 or 行为异常。升级老项目时,看到一片 import xxx = require() 报错,先检查 module 参数,这是最常见的翻车原因。
4.2 命名空间与模块:什么时候用 namespace
资料里有一句非常关键的话:「模块里不要使用命名空间」。原因是 namespace 挂到全局作用域,模块走局部作用域,两者混在一起会形成双份作用域,查找链变长,编译产物也很怪。
命名空间适合的场景是没有模块加载器、直接用多个 script 标签顺序引入的环境。比如资料里的验证器例子,把一组验证函数收敛到一个 namespace 里,避免污染 window:
namespace Validation { export interface StringValidator { isAcceptable(s: string): boolean; } const lettersRegexp = /^[a-zA-Z]+$/; export class LettersOnlyValidator implements StringValidator { isAcceptable(s: string) { return lettersRegexp.test(s); } } }逻辑说明:namespace 里不带 export 的声明外部访问不到,lettersRegexp 只有 Validation 内部能用,这是 namespace 的封装边界。外部通过 Validation.LettersOnlyValidator 取到导出成员。
参数说明:多文件 namespace 靠三斜线指令关联,写法是 /// 。这个语法在模块场景里是坏味道,资料「命名空间和模块的陷阱」里专门点名:对模块使用 /// 会让模块加载行为混乱,正确姿势是直接 import。也就是说,三斜线指令只保留给全局 script 场景用,一旦项目用了 import/export,就别再碰它。
4.3 外部 JavaScript 库与 d.ts 的编写流程
资料最后有一章「编写定义文件(.d.ts)」,很多人跳过了,但项目里引用老 JS 库时一定会碰上。d.ts 文件的流程其实很固定:先看库暴露的是全局对象还是模块导出,再决定用 declare namespace 还是 declare module。
// 全局库:暴露在 window 上的对象 declare namespace MyLib { function hello(name: string): void; }// 模块库:通过 import 引入的 npm 包 declare module 'legacy-lib' { export function hello(name: string): void; }逻辑说明:declare namespace MyLib 声明了一个全局命名空间,适合通过 script 标签引入的老库。declare module 'legacy-lib' 是给 npm 包补类型壳,import { hello } from 'legacy-lib' 之后,TS 会从这段声明里找类型。
参数说明:遇到没有任何 .d.ts 的老库,正确顺序是先查类型声明社区仓库,没有再自己补。自己在项目里新建一个 declarations.d.ts,把用到的函数逐个声明出来。注意 declare module 里的导出接口要和你实际调用的 API 对齐——多写不会报错,但会让类型检查失效;少写会让你后续访问任何未声明的属性时报错。实际上,这里最常见的习惯是先把用到的函数全部声明成 any 参数,跑通后再逐步细化类型,别一上来追求完美。
5. 泛型、推导与兼容性:五条高频避坑记录
5.1 泛型约束失效:extends 只约束类型参数,不约束运行时
现象:写了 function f (x: T),以为传进来的东西一定是对象。结果 null 和 undefined 照样能传进去,运行时一访问属性就崩。
原因:extends object 约束的是类型层面的边界,不是运行时判断。如果 tsconfig 没开 strictNullChecks,null 可以赋值给任何类型;就算开了,T extends object 也不会自动排除 undefined。泛型约束管的是「你能传什么类型」,管不了「运行时会传什么值」。
解决:在函数入口做一次显式检查,或者把约束写得更严格:
function f<T extends object>(x: T) { if (x == null) { throw new Error('x must not be null'); } }逻辑说明:这个问号判断把 null 和 undefined 一起拦住,运行时防御。泛型约束适合限制类型范围,但不能替代参数校验,尤其是写对外 API 时。
5.2 类型推导失灵:什么时候必须显式标注
现象:map 回调里返回一个对象,TS 推导出的字段类型比预期宽,比如把 null 推导成 any,后面过滤时怎么都报不对。
原因:上下文类型推导在三个场景里最容易出问题:回调函数、泛型函数、复杂对象字面量。推导结果往往是宽泛的联合类型,看起来没报错,一用就错。
解决:不要迷信推导。凡是返回对象结构的函数,显式写返回类型;凡是暴露给其他模块的 API,显式写参数和返回类型。内部实现的局部变量可以交给推导,反正错了也只在局部。
5.3 函数参数双向协变:strictFunctionTypes 下的意外
现象:把 (x: Animal) => void 的函数赋给 (x: Dog) => void,以为安全,开启 strictFunctionTypes 后直接报错。
原因:开启 strictFunctionTypes 后,函数参数是逆变检查。接受 Animal 的函数不能赋值给接受 Dog 的函数,因为调用方可能传入更具体的 Dog,而函数体里按 Animal 处理,类型上就不安全。这个规则在旧版本 TS 里是双向协变的宽松检查,新版本收紧了。
解决:回调签名里用具体类型,不要用父类型兜底。如果确实需要通用的回调,把参数声明为联合类型或者泛型,别指望 Animal 兜住所有子类。
5.4 枚举与数字字面量的兼容性误判
现象:enum Color { Red, Green },函数参数类型写 Color,传入 1 不报错,传入 2 就报错,非常迷惑。
原因:数字枚举和 number 之间存在部分兼容,枚举成员值可以被当作字面量类型使用,但反向不成立。也就是说 Color 类型实际接受的是枚举成员的值,不是任意数字。
解决:这种「枚举值当常量用」的场景,直接用 const 对象加 as const 更干净。枚举留给真正需要命名常量和反向映射的地方。资料里枚举部分如果只讲了语法没讲这个坑,一定要自己补上这条认知。
5.5 泛型工具类型的误解:keyof 和 typeof 的顺序
现象:想取一个类的所有属性名,写 type Keys = keyof MyClass 拿不到想要的集合,改成 keyof typeof MyClass 才对。
原因:typeof MyClass 取的是类的静态类型,也就是构造函数类型,keyof 它拿的是静态成员名;直接 keyof MyClass 拿的是实例成员名。如果类里没有实例属性,只有方法,keyof MyClass 拿到的是方法名,不是属性名,和预期差很远。
解决:先想清楚「我要的是值还是类型」。typeof 用在值上,keyof 用在类型上,连写 keyof typeof 就是把值先变成类型再取键名。这个顺序记牢,能少踩一半泛型工具的坑。
6. 最后一公里:声明合并与 Symbol.iterator 的实战组合
资料里的「声明合并」和「可迭代性」「Symbols」是三章独立的主题,实际使用时经常组合出有用的技巧。比如给一个类补充可迭代能力,让它可以被 for..of 消费,同时不破坏原有结构:
class Range { constructor(public start: number, public end: number) {} } interface Range { [Symbol.iterator](): Iterator<number>; } Range.prototype[Symbol.iterator] = function () { let current = this.start; const end = this.end; return { next(): IteratorResult<number> { if (current <= end) { return { value: current++, done: false }; } return { value: undefined, done: true }; } }; };逻辑说明:这里利用接口声明合并,给 Range 类补上了 [Symbol.iterator] 的类型签名;类的实现部分通过原型挂载真正的迭代器方法。声明合并让同一个类名同时拥有类实现和接口约束,类负责逻辑,接口负责类型描述,两者互不污染。
然后代码就能这样跑:
for (const n of new Range(1, 5)) { console.log(n); // 1 2 3 4 5 }逻辑说明:for..of 走的是迭代器协议,拿的是迭代器每次返回的 value。for..in 走的是属性枚举,拿到的是属性名。资料里「for..of vs for..in」一节反复强调的就是这个区别:for..in 用在数组上拿到的是字符串下标,不是元素值,这是经典翻车点。
参数说明:这种迭代器写法对 target 参数敏感。如果 tsconfig 里 target 是 ES5 或 ES3,for..of 需要编译器生成迭代器辅助代码,依赖 Symbol 相关 polyfill;如果 target 是 ES2015 或更高,原生支持迭代器,不需要额外处理。资料里「代码生成」一节对这部分有专门说明。
从那以后,我遇到「对象不像数组但想被遍历」的需求,第一反应就是声明合并加 Symbol.iterator,而不是硬塞一个 toArray 方法。这个套路本身是通用的,而且声明合并的边界恰好能保证类型和实现在同一个名字下不分离。希望帮到你。
本文还有配套的精品资源,点击获取