简介:面向TypeScript初学者与进阶者的数据类型专项PDF教程,系统梳理布尔值、数字、字符串、数组、元组、枚举、接口等核心知识点,配有大量可直接运行的代码示例,帮助读者建立静态类型检查的整体认知。教程以单个PDF文件呈现,体积约1.75MB,轻量便携,适合碎片化学习或作为案头速查手册,目前已有1099人学习下载。内容从基础类型讲起,逐渐过渡到外部枚举、接口的可选属性、函数类型、类类型及实现接口等进阶话题,并通过正反示例展示常见写法与易错点。通过完整学习,读者既能掌握TypeScript类型标注的基本用法,也能理解接口约束与类实现的关系,为编写更健壮、可维护的代码打下坚实基础。
1. TypeScript 编译后类型全没了:类型擦除才是理解一切机制的地基
这份 TypeScript 课程资源翻到最前面,你看到的是一堆类型标注的语法;但把整套内容读完后回头想,会发现一个被默认忽略的事实:TS 编译器把类型检查做完之后,会把所有类型从输出里抹掉。let isDone: boolean = false编译成 JavaScript 只剩let isDone = false;interface、泛型参数、类型别名全部消失。这意味着 TypeScript 的所有类型机制都建立在一个完全没有类型可言的运行时之上——它是一层纯编译期的校验,而不是运行时的保护。很多初学者把 TS 当「带类型的 JavaScript」来记语法,于是接口、泛型、声明合并这些特性变成了互不相干的记忆点。真正该建立的底层认知是:类型擦除是这套体系的地基,理解它,后面所有「为什么这么设计」才能串成一条线。
2. 类型兼容性:为什么多一个属性不报错,少一个反而报错
2.1 结构类型系统:形状决定一切,而不是名字
资源里的接口初探用了这样一个例子:定义interface LabelledValue { label: string },然后写printLabel(labelledObj: LabelledValue)。调用时传的是一个变量myObj,这个变量比接口多了一个size属性,TypeScript 却放行了。这是整套类型系统最核心的一条规则:TS 判断类型兼容性不看类型名,只看形状——只要结构里包含label: string,这个对象就能当LabelledValue用。
这跟 Java 或 C# 那种名义类型系统完全不同。名义类型系统里,两个类即使成员一模一样,只要声明来源不同,就不能互相赋值。TS 选择结构类型,直接原因是 JavaScript 生态里到处是「长得像但从来没声明过类型」的对象:第三方库返回的匿名对象、后端 JSON 解析出来的数据、别人随手写的普通对象。如果要求先声明继承关系或实现关系才能传入,TS 在真实 JS 代码上根本跑不起来。
interface LabelledValue { label: string; } function printLabel(labelledObj: LabelledValue) { console.log(labelledObj.label); } let myObj = { label: "size 10 object", size: 10 }; printLabel(myObj); // OK:结构里包含 label,多出来的 size 不影响 printLabel({ label: "size 10 object", size: 10 }); // Error:对象字面量会触发额外属性检查同一个对象,用变量传不报错,直接写字面量就报错,这背后是两种并行的检查。变量场景只做结构兼容性比较:myObj拥有label: string,完全满足LabelledValue的要求,多余的size属于「无害的多余」,直接忽略。字面量场景多了一层 freshness 检查——对象字面量是「新鲜的」,TS 认为它上面出现的每一个属性都是你刻意写的,size不在LabelledValue已知属性集合里,于是判定为大概率是笔误。这就是「多一个属性不报错,少一个属性反而报错」的第一层真相。
2.2 对象字面量的额外属性检查:防的是拼写错误
资源里接口的可选属性一节,SquareConfig的color和width都用?标记。可选属性的语义是「可以没有,但一旦出现,类型必须正确」。真正容易踩坑的是createSquare({ colour: "red", width: 100 })这种调用——属性名拼错,TS 报错说colour不是已知属性。
interface SquareConfig { color?: string; width?: number; } function createSquare(config: SquareConfig) { let area = config.width ? config.width * config.width : 0; return { color: config.color || "white", area }; } createSquare({ colour: "red", width: 100 }); // Error: colour 被当成多余属性 let c = { colour: "red", width: 100 }; createSquare(c); // OK:变量不做 freshness 检查这个检查的本质是编译期的笔误检测。color和colour在运行时根本不会暴露问题——config.colour只是undefined,程序照常运行。TS 在这里故意比运行时更严格,就是为了把这种「写错但不崩」的 bug 提前拦下。解决办法有两个:要么老老实实改拼写,要么用类型断言as SquareConfig或先赋给变量,明确告诉编译器「我知道这里有额外属性」。
2.3 函数参数比较:为什么参数少的能赋值给参数多的
类型兼容性章节里,TS 比较两个函数时有一套独立规则。核心逻辑是:源函数的参数列表必须能「接住」目标函数被调用时可能传入的所有参数。所以(a: number) => void可以赋值给(a: number, b: number) => void,因为调用方至少会传两个参数,少声明一个参数不会出问题;反过来,要求两个参数的函数不能赋给只传一个参数的场景,因为拿不到需要的第二个参数。
type Handler = (event: { type: string }, payload?: number) => void; let h1: Handler = (event) => {}; // OK:少声明可选参数 let h2: Handler = (event, payload) => {}; // OK // let h3: Handler = (event, payload, extra) => {}; // Error:参数比目标多资源里专门提了「函数参数双向协变」这个概念。理论上函数参数位置应该只允许逆变才安全,但 TS 默认允许双向协变,原因是 JavaScript 回调文化里充满了「只取用一部分参数」的写法——数组的forEach、map回调经常只写第一个参数。如果严格要求逆变,大量合法 JS 代码会被误伤。开启strictFunctionTypes后,独立的函数类型声明会收紧成逆变检查,但方法声明仍然保持双向协变。这是安全性和易用性之间的一个明确取舍。
2.4 赋值兼容性:枚举、类、泛型的特殊规则
类型兼容性的最终落点是「可赋值性」:A 类型能否赋给 B 类型。枚举在这里有个容易忽略的细节:数字枚举与number互相兼容,所以let n: number = s合法;但字符串枚举不兼容string,它被当成字面量联合类型处理。类的情况更特殊:成员形状相同的两个类可以互相赋值,但只要类里声明了private或protected成员,就必须要求双方来自同一个声明来源。
enum Status { Ready, Waiting } let s: Status = Status.Ready; let n: number = s; // OK:数字枚举与 number 兼容 class Animal { private name: string = ""; } class Dog extends Animal {} class Cat { private name: string = ""; } let animal: Animal = new Dog(); // OK:Dog 继承自 Animal // let animal2: Animal = new Cat(); // Error:private 来自不同声明这是结构类型系统里少数会查「身份」的地方。private name意味着这个类有一个外部不可见的内部槽位,如果只按形状匹配,一个结构与Animal完全相同的Cat也能赋值给它,封装语义就被破坏了。TS 在这里让「声明来源」优先于「形状匹配」,本质上是结构类型对真实运行时语义的一次妥协。
3. 接口:一个语法如何同时描述对象、函数、数组和类
3.1 接口的形态扩展:函数、数组、类、可迭代协议
接口在 TS 里不只是描述对象的键值结构。资源里接口章节一口气展示了四种形态:函数类型接口、数组类型接口、类类型接口、混合类型接口。函数类型接口用调用签名描述函数的形状:
interface SearchFunc { (source: string, subString: string): boolean; } let mySearch: SearchFunc; mySearch = function (source, subString) { return source.search(subString) !== -1; };接口名只是给这个函数形状起一个别名,编译后SearchFunc直接消失,mySearch就是一个普通函数变量。数组类型接口用索引签名描述「按下标取值」的结构:
interface StringArray { [index: number]: string; }TS 里number[]和Array<number>本质上就是这种索引签名的语法糖。类类型接口用new签名描述「可构造性」,这也是资源里「类静态部分与实例部分的区别」要解决的问题:
interface ClockConstructor { new (hour: number, minute: number): ClockInterface; } interface ClockInterface { tick(): void; } function createClock( ctor: ClockConstructor, hour: number, minute: number ): ClockInterface { return new ctor(hour, minute); }implements只检查类的实例侧,构造函数的形状必须用独立接口描述。接口同样能描述可迭代协议——TS 判断一个类型能否用于for..of,靠的是它是否实现了Symbol.iterator的结构,也就是有没有返回带next()方法的迭代器对象。这解释了为什么for..in遍历键名、for..of遍历值:后者走的是迭代器接口,而这个接口本身也是一份结构类型描述。
3.2 类的 private 与 protected:编译期幻影
类章节里,private和protected是最容易让人误解的部分。TS 的private只拦编译期,编译后的 JavaScript 里根本没有私有性这个概念。
class Animal { private name: string = ""; public age: number = 0; }编译成 ES2015 代码后,name就是一个普通的实例字段,运行时用obj.name照样拿得到。TS 的private本质是一道编译期栅栏:在类型检查阶段禁止外部访问,但编译产物完全透明。真正需要在运行时实现私有的场景,得用 JavaScript 自己的#name私有字段。参数属性是另一个典型语法糖:
class Octopus { readonly id: number; constructor(readonly name: string, id: number) { this.id = id; } }constructor(readonly name: string)声明了实例属性name,编译后构造函数里会自动生成this.name = name的赋值。常见误用是以为构造参数天然会成为实例属性——实际上只有加了private、protected、public或readonly前缀的参数才会被编译器生成赋值代码。
抽象类也遵循同样的擦除逻辑。abstract关键字编译后被移除,抽象方法在基类里根本没有实现,运行时如果调用一个没有被子类覆盖的抽象方法,得到的是undefined而不是一个明确报错——因为编译器认为你已经通过「必须继承并实现」保证了安全,运行时不负责兜底。
3.3 声明合并:同名声明为什么能叠加
声明合并是 TS 里最反直觉、也最考验底层理解的机制。两个同名接口可以合并成一个类型:
interface Box { height: number; } interface Box { width: number; } let box: Box = { height: 2, width: 3 }; // 两个接口合并成一个类型这个合并发生在编译器内部维护的声明表里,运行时当然没有Box这个对象。真正有趣的是命名空间与类、函数、枚举的合并——它的编译产物暴露了机制本质:
namespace Validation { export interface StringValidator { isAcceptable(s: string): boolean; } export class LettersOnlyValidator implements StringValidator { isAcceptable(s: string) { return /^[a-zA-Z]+$/.test(s); } } }var Validation; (function (Validation) { class LettersOnlyValidator { isAcceptable(s) { return /^[a-zA-Z]+$/.test(s); } } Validation.LettersOnlyValidator = LettersOnlyValidator; })(Validation || (Validation = {}));命名空间的编译产物是「对象 + IIFE」。多个同名命名空间通过在Validation || (Validation = {})上反复执行 IIFE 实现合并。接口这类纯类型声明没有编译产物,所以接口之间的合并在编译期声明表里完成;类与命名空间的合并,本质上是「类型侧合并进声明表、值侧合并进同一个对象」。这一条规则就能解释为什么「类 + 命名空间」合法而「类 + 类」非法:两个类都有值语义,值侧无法调和。
4. 泛型与类型推导:编译器如何在约束和推断之间找平衡
4.1 泛型约束:在不知道类型的情况下保证安全
泛型章节从 Hello World 开始:function identity<T>(arg: T): T。编译后的 JavaScript 是一条没人会多想的事实:
function identity(arg) { return arg; }类型参数T被完全擦除,运行时不做任何检查。泛型的价值全在编译期:调用点推断出T具体是什么,然后用这个类型检查函数体内外的用法。不带约束的泛型几乎是透明的,identity(123)返回number,identity("x")返回string——类型跟着参数走。
但一旦需要访问参数的属性,问题就来了。教程里经典的翻车点是function loggingIdentity<T>(arg: T): T里写arg.length报错:编译器不知道T有没有length。解决办法是加约束:
interface Lengthwise { length: number; } function loggingIdentity<T extends Lengthwise>(arg: T): T { console.log(arg.length); // OK:约束下编译器知道有 length return arg; }extends Lengthwise把「任意类型 T」收窄到「至少带 length 属性的类型」。约束在编译后同样被擦除,运行时没人帮你检查参数有没有长度——这是调用方在编译期已经被检查过、所以运行时无需再验账。
4.2 在泛型约束中使用类型参数:keyof 的组合拳
资源里「在泛型约束中使用类型参数」一节给了个非常能体现设计意图的例子:
function getProperty<T, K extends keyof T>(obj: T, key: K) { return obj[key]; } let x = { a: 1, b: 2, c: 3 }; getProperty(x, "a"); // OK,返回类型被推断为 number // getProperty(x, "m"); // Error: "m" 不是 x 的 keykeyof T是类型层面的键集合,K extends keyof T把第二个类型参数约束为「T 中真实存在的键」。编译器在编译期完成两件事:检查传入的key确实是obj的属性;把返回类型精确推断成对应属性的类型。运行时getProperty就是一个再普通不过的(obj, key) => obj[key],没有任何魔法。这个组合在表单字段映射、配置覆盖这类场景里被广泛使用,因为它在不写任何运行时校验代码的前提下,把「取错键」这个错误直接变成编译错误。
4.3 最佳通用类型与上下文类型:推导结果可能超出预期
类型推导章节讲了一个微妙的机制。数组字面量会被推导出一个「最佳通用类型」:
let x = [0, 1, null]; // 推导为 (number | null)[]编译器尝试在所有元素类型里找一个共同的父类型;找不到合适的就退化成联合类型。资源里用的是动物园例子——如果Rhino和Elephant都继承自Animal,[new Rhino(), new Elephant(), new Animal()]会被推导成Animal[],而不是(Rhino | Elephant | Animal)[]。这个推导逻辑不是拍脑袋定的:TS 要保证对数组元素做操作时,类型尽量是「够用但不丢信息」的。
上下文类型则是反过来的推导方向。赋值目标本身携带类型信息,TS 用这个信息反向推断表达式:
window.onmousedown = function (mouseEvent) { console.log(mouseEvent.button); // mouseEvent 被推成 MouseEvent };因为onmousedown的类型已知是(this: Window, ev: MouseEvent) => any,所以函数表达式里的参数自动获得MouseEvent类型。同一个函数表达式如果独立声明,参数就退化成any。这就是为什么「显式标注」在复杂场景仍然必要——推导能省事,但推导的上下文一变,类型就跟着变。
4.4 泛型类与混入:类型层和运行时层完全分离
泛型类的编译产物同样只有一条规则:类型参数擦除,实例成员保留。
class GenericNumber<T> { zeroValue!: T; add!: (x: T, y: T) => T; } let myNum = new GenericNumber<number>(); myNum.zeroValue = 0; myNum.add = (x, y) => x + y;编译出的类定义里没有任何<T>的痕迹。混入示例则把这种「类型层与运行时层分离」演到了极致:
class Disposable { isDisposed: boolean = false; dispose() {} } class Activatable { isActive: boolean = false; activate() {} } interface SmartObject extends Disposable, Activatable {} class SmartObject implements Disposable, Activatable {} function applyMixins(derivedCtor: any, baseCtors: any[]) { baseCtors.forEach(baseCtor => { Object.getOwnPropertyNames(baseCtor.prototype).forEach(name => { Object.defineProperty( derivedCtor.prototype, name, Object.getOwnPropertyDescriptor(baseCtor, name)! ); }); }); } applyMixins(SmartObject, [Disposable, Activatable]);JavaScript 的类不支持多重继承,但接口可以多重扩展。interface SmartObject extends Disposable, Activatable在类型侧描述了合并后的形状,编译器看到smartObject.dispose()和smartObject.activate()都不报错;class SmartObject implements Disposable, Activatable让这个类在类型上满足两个类的实例结构,本体留空;真正把方法搬到SmartObject.prototype上的是最后一个applyMixins调用。interface和implements编译后全部消失,运行时只留下原型链操作。
5. 模块与命名空间:两种组织代码方式的坑
5.1 模块的导出与生成代码
模块章节的底层逻辑是:模块语法不是类型,导入导出在运行时真实存在,所以编译后会生成对应代码。资源里区分了导出声明、导出语句、重新导出、导入、默认导出和export =。关键是理解编译目标的影响——当target是 ES5/ES3 时,TS 根据module选项把import/export编译成 CommonJS 或 AMD 的require/exports;当目标是 ES2015 及以上时,保留原生模块语法。
// math.ts export function square(x: number) { return x * x; } export default class Calculator {} // app.ts import Calc, { square } from "./math";export =是 CommonJS 风格的特殊导出方式,它把整个模块导出一个单一对象,配合import = require()使用。这个语法在纯 ES 模块里没有对应物,属于 TS 为兼容旧 Node.js 代码保留的机制。打开编译后的 JS,能看到exports.square = ...这类赋值——模块系统是少数「类型被擦除后代码仍然留下语义」的部分。
5.2 命名空间的 IIFE 与别名机制
命名空间是为「无模块环境」设计的组织方式。没有import/export的时代,多个脚本文件靠全局变量共享,命名空间用 IIFE 把符号挂到全局对象上避免污染。资源里的验证器例子展示了完整形态:单文件里的命名空间、分离到多文件的/// <reference path="..." />、命名空间之间的别名。
namespace Shapes { export namespace Polygons { export class Triangle {} } } import polygons = Shapes.Polygons; let t = new polygons.Triangle();这里import polygons = Shapes.Polygons不是 ES6 的模块导入,它编译成一个普通变量引用指向Shapes.Polygons。别名机制让长长的命名空间路径在局部有个短名字。命名空间模块在运行时就是一个普通对象,这也是为什么命名空间和类能合并——它们共享同一个「对象 + 挂载」的运行时模型。
但当项目进入模块体系后,文件本身就是作用域,命名空间开始变得多余。资源里「命名空间和模块的陷阱」直接把几种误用点名了。
5.3 避坑记录
5.3.1 对模块文件用 /// reference 引用,结果符号找不到
现象:在模块 A 顶部写/// <reference path="./b.ts" />,然后直接用 B 里导出的类,编译报「名称不存在」。
原因:/// reference只在全局脚本之间建立编译顺序关联。模块文件自带import/export作用域,reference 不会把任何符号引入当前作用域,它只是告诉编译器「这个文件也要参与编译」。
解决:改成import { B } from "./b"。模块间共享代码,import是唯一通道。
5.3.2 外部枚举编译后消失了,运行时访问未定义
现象:写了declare enum Fish { Shark = 1, Tuna },代码里console.log(Fish.Shark),浏览器报Fish is not defined。
原因:外部枚举是「声明一个已经存在的 JS 枚举」用的,declare表示值来自外部环境。编译后整个枚举类型被擦除,运行时根本没有Fish对象。
解决:枚举是自己实现的就用普通enum;是描述外部库的,确认外部脚本确实在运行时提供了Fish这个全局对象。
5.3.3 模块里套命名空间,引用路径又臭又长
现象:写了export namespace Models { export interface User { ... } },使用时必须import { Models }然后写Models.User,类型和值搅在一起。
原因:模块文件顶层已经是作用域,再加 namespace 等于套了两层包装。资源里的「创建模块结构指导」直接写了「模块里不要使用命名空间」。
解决:去掉模块内的 namespace 包裹,直接export interface User。需要聚合时用命名空间导入模式import * as Models from "./models"——这是导入侧的聚合,导出侧保持扁平。
5.3.4 类与类同名声明合并报错
现象:写了两个同名class,期望像接口合并一样叠加成员,编译报Duplicate identifier。
原因:接口是纯类型,可以并入声明表;类携带运行时值,两个同名类在值侧无法调和。
解决:用「类 + 同名命名空间」补静态成员,或用「类 + 同名接口」补实例类型。声明合并在实际项目里最常见的用法就是「类 + 命名的静态成员扩展」。
5.3.5 声明文件里顶层 export 和 declare namespace 混用
现象:写.d.ts时既export function foo()又在下面declare namespace foo,使用方要么拿不到静态成员,要么 IDE 提示混乱。
原因:顶层有export时,整个文件被视为模块,declare namespace里的类型不会自动作为模块导出暴露出来。
解决:先确认这个.d.ts是模块声明还是全局声明。模块声明就把所有导出对象放顶层;描述全局库时用declare namespace并且不要写顶层export。资源里的「危险信号」一节就是在提醒检查这种混用。
6. 结构类型还是名义类型:TypeScript 为了可迁移性让步了什么
6.1 结构类型是给 JavaScript 生态的「迁移友好」设计
结构类型系统让 TS 能在完全不修改现有 JS 代码的前提下逐步加类型。任意一个匿名对象,只要结构匹配,就能通过类型检查。代价是类型错误的检测精度低于名义类型:两个来自不同地方、长得一样的类可以互相赋值,误用风险落在开发者头上。TS 用 private 来源检查和对象字面量的额外属性检查,修补了最容易出问题的几个缺口。
| 维度 | 结构类型 | 名义类型 |
|---|---|---|
| 判定依据 | 成员形状 | 类型名 / 声明来源 |
| JS 互操作 | 天然契合 | 需要显式适配 |
| 迁移成本 | 低,渐进采用 | 高,需先声明关系 |
| 运行时开销 | 无(编译期擦除) | 可能依赖运行时元信息 |
| 误用风险 | 同类形状可互赋 | 声明来源天然隔离 |
6.2 声明机制:类型层可以无限扩展而不影响运行时
枚举、接口、泛型约束、声明合并,这些特性在运行时全部消失。需要运行时语义的部分,比如枚举值、类方法、原型链,用 JavaScript 原生机制承载;只需要编译期语义的部分,比如接口、类型别名、泛型参数、keyof约束,一律零成本擦除。这份教程里的每一章,其实都能用同一个问题来验证理解:「编译之后,这段代码还剩什么?」
从那以后我每次写完一版公共类型,都会强制问自己一遍:编译完之后,这段类型还剩什么?如果什么都不剩,说明我写对了,它是纯编译期契约;如果还剩点东西,我就得盘算它会不会成为运行时的包袱——命名空间编译成对象、混入编译成原型操作、参数属性编译成构造函数里的赋值,这些都是有运行时成本的「值」。把类型层的归类型层,把运行时层的归运行时层,TS 的绝大多数反直觉行为都能在这个框架里找到解释。希望帮到你。
本文还有配套的精品资源,点击获取