TypeScript 基础:从 Java 视角看类型系统
为什么要学 TypeScript
JS 本身是动态弱类型语言,写大项目时"拼写错个属性名运行时才炸"是家常便饭。TypeScript 在编译时做类型检查,编译完再扔掉类型,产出的还是普通 JS。
一句话类比:TS 之于 JS,就像 Java 之于 Groovy —— 加上类型后,IDE 能准确补全、重构能批量改、单测能少写一半。
Vue 3 源码、Element Plus、VueUse 全部用 TS 写。学 Vue 的路上必然要过 TS 这一关。
1. 基础类型
let name: string = 'foo'
let age: number = 18
let active: boolean = true
let nothing: null = null
let notDefined: undefined = undefined
// 数组两种写法等价
let nums: number[] = [1, 2, 3]
let nums2: Array<number> = [1, 2, 3]
// 元组:固定长度和类型的数组
let pair: [string, number] = ['foo', 18]
Java 对照:
| Java | TS |
|---|---|
String |
string |
int / double |
number(JS 只有一种数字类型) |
boolean |
boolean |
null |
null |
Object 未初始化 |
undefined |
int[] |
number[] |
Pair<String, Integer> |
[string, number] |
注意:
- TS 类型用小写
string / number / boolean。大写的String / Number / Boolean是包装类型,几乎不用 - JS 没有
int / long / float / double之分,只有一个number(IEEE 754 双精度浮点)
2. 类型推导:大多数时候不用写类型
// 不用写 : string,TS 自己能推出来
let name = 'foo'
// 函数返回值也能推导
function add(a: number, b: number) {
return a + b // TS 推断返回 number
}
Java 对照:类似 Java 10+ 的 var。
建议:变量赋值时尽量省略类型标注,让 TS 自己推。只在函数参数、API 边界、公共类型上显式写类型。
3. 联合类型:一个值可能是多种类型之一
这是 TS 和 Java 最不一样的点之一。
let id: string | number
id = 'abc'
id = 123 // 都合法
// 常用于函数参数
function format(input: string | number) {
if (typeof input === 'string') {
return input.toUpperCase() // 这里 TS 知道 input 是 string
}
return input.toFixed(2) // 这里 TS 知道 input 是 number
}
Java 对照:Java 没有原生联合类型。类似场景要么用 Object 外加 instanceof 判断,要么用泛型 + 多个重载方法。TS 的联合类型优雅得多。
typeof 窄化:TS 能根据 typeof 自动推断出在 if 块里的具体类型,叫"类型守卫"。
4. 接口(interface):给对象定形状
interface User {
id: number
name: string
email?: string // 可选属性(? 表示可以不传)
readonly createdAt: Date // 只读属性
}
const u: User = {
id: 1,
name: 'foo',
createdAt: new Date(),
}
u.createdAt = new Date() // ❌ readonly,改不了
Java 对照:interface User 和 Java 的接口类似,但更偏向"数据契约"(更像 Java 的 record 或 DTO 类)。
和 Java 接口的关键差异:
- TS 接口描述数据结构,不像 Java 接口那样只能定义方法
- TS 接口可以重复声明,同名会自动合并(declaration merging)
- 实现接口不用
implements关键字,结构匹配就算实现(结构型类型系统,下文讲)
interface vs type 别名
两者在大多数场景可以互换:
interface User { name: string }
type User2 = { name: string }
选择规则:
- 对象结构优先用
interface(更符合语义,支持声明合并) - 联合类型、元组、映射类型用
type(interface做不了)
5. 结构型类型系统(Structural Typing)
这是 TS 和 Java 最本质的差异。
interface Duck {
quack(): void
}
class Dog {
quack() { // Dog 没有 implements Duck,但有 quack 方法
console.log('...wang?')
}
}
const d: Duck = new Dog() // ✅ 合法!结构匹配就算 Duck
Java 对照:
- Java 是名义型类型系统(Nominal Typing):必须显式
implements Duck才算 Duck - TS 是结构型类型系统(Structural Typing):长得像就是,不需要声明继承关系
这像什么?像 Go 的 interface。写前端时遇到"诶这个对象怎么我一字不写就能当参数传"别慌,这是 TS 的特性,不是 bug。
6. 泛型:和 Java 几乎一样
// 泛型函数
function first<T>(arr: T[]): T | undefined {
return arr[0]
}
first<number>([1, 2, 3]) // 显式指定 T = number
first([1, 2, 3]) // 不写也行,TS 能推导出 T = number
// 泛型接口
interface ApiResult<T> {
code: number
data: T
}
const res: ApiResult<User> = { code: 0, data: { id: 1, name: 'foo', createdAt: new Date() } }
// 泛型约束(类似 Java 的 <T extends Comparable>)
function getLength<T extends { length: number }>(x: T) {
return x.length
}
getLength('hello') // ✅ string 有 length
getLength([1, 2, 3]) // ✅ 数组有 length
getLength(123) // ❌ number 没有 length
Java 对照:
<T>语法一致T extends Something语法一致- 差别:TS 的泛型是编译时擦除的(和 Java 一样),但类型推导更强,很多时候不用显式指定
7. 字面量类型和枚举
// 字面量类型:值只能是具体的某几个字符串/数字
type Size = 'small' | 'medium' | 'large'
let size: Size = 'small'
size = 'xlarge' // ❌ 报错
// 枚举
enum Role {
Admin = 'ADMIN',
User = 'USER',
Guest = 'GUEST',
}
const r: Role = Role.Admin
Java 对照:
- 字面量联合类型在 Java 里没直接对应物(Java 14+ 的 sealed + String 值可以模拟)
- TS 的
enum和 Java 的enum语法相似,但前端社区更推荐用字面量联合类型type Role = 'admin' | 'user' | 'guest',产物更小、更灵活
8. 函数类型
// 函数类型声明
type BinaryOp = (a: number, b: number) => number
const add: BinaryOp = (a, b) => a + b
const sub: BinaryOp = (a, b) => a - b
// 可选参数和默认参数
function greet(name: string, greeting: string = 'Hello') {
return `${greeting}, ${name}!`
}
Java 对照:函数类型相当于 Java 的 BiFunction<Integer, Integer, Integer>,但 TS 写起来短得多。
9. 工具类型:TS 的"装备栏"
TS 内置了一堆操作类型的工具类型,Vue 和 Element Plus 的类型里到处都是它们:
interface User {
id: number
name: string
email: string
}
// Partial:所有属性变可选(常用于 PATCH 接口)
type UserPatch = Partial<User>
// 等价于 { id?: number; name?: string; email?: string }
// Required:所有属性变必填
type UserStrict = Required<User>
// Readonly:所有属性变只读
type UserReadonly = Readonly<User>
// Pick:只挑选部分字段
type UserSummary = Pick<User, 'id' | 'name'>
// Omit:排除部分字段
type UserCreate = Omit<User, 'id'> // 创建时还没有 id
// Record:构造 Map 类型
type UserMap = Record<number, User> // 等价于 { [key: number]: User }
Java 对照:Java 反射能做类似事但笨重得多。TS 的工具类型是编译时的字符级转换,运行时零开销。
常用组合(项目里的 src/types/note.ts 已经用了):
// 从完整 Note 中去掉 raw 和 html,用于列表场景
export type NoteListItem = Omit<Note, 'raw' | 'html'>
10. 类型断言 vs 类型守卫
有时 TS 推不准,需要手动告诉它"相信我,这玩意就是 XXX":
// 类型断言(等价于 Java 的强制类型转换 (T) obj)
const el = document.querySelector('#app') as HTMLDivElement
el.style.color = 'red'
// 类型守卫:通过函数帮 TS 推断
function isUser(x: unknown): x is User {
return typeof x === 'object' && x !== null && 'name' in x
}
function handle(input: unknown) {
if (isUser(input)) {
input.name // ✅ TS 知道这里 input 是 User
}
}
原则:尽量少用类型断言 as,多用类型守卫。断言是告诉 TS "闭嘴我对",守卫是教 TS "这样推导"。
特殊的 as const:
const config = {
mode: 'dev',
port: 3000,
} as const
// 推导类型:{ readonly mode: 'dev'; readonly port: 3000 }
把对象冻成字面量类型,写配置常量时很好用。
11. 实战:看懂项目里的类型声明
回头看看本项目 src/types/note.ts:
export interface NoteMeta {
title: string
category: string
order: number
date: string
tags: string[]
summary: string
}
export interface Note extends NoteMeta {
slug: string
raw: string
html: string
}
export type NoteListItem = Omit<Note, 'raw' | 'html'>
读懂的关键:
NoteMeta是笔记头部 Frontmatter 的形状(纯数据)Note extends NoteMeta在 interface 里就是继承,Note包含NoteMeta的所有字段再加三个自己的NoteListItem = Omit<Note, 'raw' | 'html'>从 Note 里去掉两个大字段,列表场景用,减小数据量
这三行类型,换成 Java 需要三个 class + 若干 DTO 转换方法。TS 靠类型组合零运行时成本表达了同样的语义。
12. 原理深挖:类型擦除 = 编译期的"语法洁癖"
TS 的核心承诺是:类型只在编译期存在,编译完就被擦掉,产物是纯 JS。
// 你写的 TS
interface User { id: number; name: string }
function greet(u: User): string {
return `Hello, ${u.name}`
}
const x = greet({ id: 1, name: 'foo' })
// 编译后的 JS
function greet(u) {
return `Hello, ${u.name}`
}
const x = greet({ id: 1, name: 'foo' })
interface User 完全消失,: User / : string 全部擦除。运行时根本不存在 User 这个东西。
Java 对照:
| 维度 | Java | TypeScript |
|---|---|---|
| 何时检查类型 | 编译期 + 运行时(class 文件保留类信息) | 仅编译期 |
| 反射拿类型 | obj.getClass() 能拿到具体类 |
拿不到——typeof 只返回 'object' |
| 泛型擦除 | 擦除(List<String> 运行时只是 List) | 也擦除(连 instanceof Array<string> 都写不出) |
| 强转失败 | 运行时 ClassCastException |
运行时没有任何提示——错就错了 |
关键推论——很多人没意识到:
as断言不做任何运行时检查:const u = data as User哪怕 data 是null,编译过、运行炸- 类型守卫必须用运行时能验证的特征:
typeof / instanceof / 'name' in obj这些能在 JS 里跑的判断,不能用 TS 类型本身判断 - API 边界要做 runtime 校验:后端返回的 JSON,TS 类型只是"我们约定好的形状",实际不一定符合。Zod / Valibot 这类库就是干这个的
import { z } from 'zod'
const UserSchema = z.object({ id: z.number(), name: z.string() })
const data = await fetch('/api/user').then(r => r.json())
const user = UserSchema.parse(data) // 不符合 schema 直接抛
把"运行时校验"和"编译期类型"绑在一起,是大型 TS 项目的标配。
13. 原理深挖:协变 / 逆变(为什么这赋值合法那个不合法)
你迟早会遇到这种诡异报错:
let dogs: Dog[] = []
let animals: Animal[] = dogs // 在 TS 里合法(数组协变)
// 但函数参数反过来
type Handler<T> = (x: T) => void
let handleAnimal: Handler<Animal>
let handleDog: Handler<Dog> = handleAnimal // 合法(参数逆变)
let handleAnimal2: Handler<Animal> = handleDog // ❌ 不合法
这就是协变(covariance)和逆变(contravariance)。
直觉理解:
- 协变:返回值类型/容器元素类型,子类能替父类
- "需要 Animal 的地方,给 Dog 也行"——Dog 是更具体的 Animal
- 逆变:函数参数类型,父类能替子类
- "需要能处理 Dog 的函数,给一个能处理任意 Animal 的函数也行"——它的处理能力更宽
- 反过来不行:要求处理 Animal,给一个只会处理 Dog 的,遇到 Cat 就炸
Java 对照:
List<? extends Animal> = List<Dog> // 协变(PECS 的 extends)
Consumer<? super Dog> = Consumer<Animal> // 逆变(PECS 的 super)
TS 里默认参数是双变(bivariant,松一点的逆变),开 strictFunctionTypes 才严格逆变。Vue 的 emit 类型签名、Pinia 的 action 类型这些常见报错背后都是这套规则。
记忆口诀:生产者协变,消费者逆变(PECS:Producer Extends, Consumer Super)。
14. 原理深挖:条件类型 + infer = 类型层面的模式匹配
TS 类型系统是图灵完备的小型语言。条件类型是它的 if-else,infer 是它的解构。
// 条件类型:T 是 string 的子类型?
type IsString<T> = T extends string ? true : false
type A = IsString<'hello'> // true
type B = IsString<123> // false
// infer:从类型里"提取"一部分
type ReturnType<T> = T extends (...args: any[]) => infer R ? R : never
type Fn = () => { id: number }
type R = ReturnType<Fn> // { id: number }
// 联合类型分发:条件类型作用在联合类型上,会"分发"到每一项
type ToArray<T> = T extends any ? T[] : never
type X = ToArray<string | number> // string[] | number[],不是 (string | number)[]
Java 对照:Java 完全没有等价物。Java 泛型只能"声明"约束,没法在类型层面做"如果...就..."的派生。
Vue 里的常见用法:
// 从一个 props 类型自动推出 emit 类型
type EmitsFromProps<P> = {
[K in keyof P as K extends `on${infer Event}`
? Uncapitalize<Event>
: never]: P[K] extends ((...a: infer A) => any) ? A : never
}
这种"喂进去一个组件类型,吐出 emit 签名"的魔法,全靠条件类型 + infer + 模板字面量类型组合实现。Element Plus、Vue Router 的类型库里到处都是。
Awaited<T> 是另一个常见的:递归剥离 Promise 包装:
type Awaited<T> = T extends Promise<infer U> ? Awaited<U> : T
type R = Awaited<Promise<Promise<number>>> // number
async 函数的返回类型推导背后就是它。
15. 原理深挖:映射类型 = 对象上的"循环"
// Partial 的真实定义
type Partial<T> = {
[K in keyof T]?: T[K]
}
// Required 的定义
type Required<T> = {
[K in keyof T]-?: T[K] // -? 移除可选标记
}
// Readonly 的定义
type Readonly<T> = {
readonly [K in keyof T]: T[K]
}
[K in keyof T] 就是类型层面的 for-in:遍历 T 的所有键,构造一个新类型。? 是修饰符,前面加 + / - 可以增删可选标记和 readonly 标记。
自己写一个——把所有方法变成异步:
type AsyncMethods<T> = {
[K in keyof T]: T[K] extends (...args: infer A) => infer R
? (...args: A) => Promise<R>
: T[K]
}
interface UserService {
getById(id: number): User
save(u: User): void
}
type AsyncUserService = AsyncMethods<UserService>
// 等价于:
// {
// getById(id: number): Promise<User>
// save(u: User): Promise<void>
// }
后端写远程调用的"客户端代理"自动给所有方法套 Promise,就是这个套路。
16. 原理深挖:结构匹配的代价 + 严格模式
第 5 节讲了结构型类型——长得像就算实现。但有一个让 Java 开发者频繁踩坑的细节:
interface Point { x: number; y: number }
function draw(p: Point) { /* ... */ }
draw({ x: 1, y: 2, z: 3 }) // ❌ 字面量上多余字段会报错
const p = { x: 1, y: 2, z: 3 }
draw(p) // ✅ 把变量传进去就过了
这叫多余属性检查(excess property check)——TS 对字面量比对变量严格。背后的设计哲学:你直接写出来的字面量更可能是手误,先存到变量再传通常是有意为之。
关键开关(tsconfig.json):
{
"compilerOptions": {
"strict": true, // 一键打开全部严格模式(推荐)
"noImplicitAny": true, // 不允许隐式 any
"strictNullChecks": true, // null/undefined 不能赋给非空类型
"strictFunctionTypes": true, // 函数参数严格逆变
"noUncheckedIndexedAccess": true // arr[i] 的类型是 T | undefined
}
}
noUncheckedIndexedAccess 是隐藏的好东西——开了之后 arr[i] 不再被推成 T,而是 T | undefined:
const list = [1, 2, 3]
const x = list[100] // 关闭:number;开启:number | undefined(迫使你处理)
这在 Java 里对应"数组越界异常",但 Java 是运行时炸,TS 让你编译期就处理。Vue 项目建议开,能在编译期截下大量 Cannot read property 'xxx' of undefined。
17. 原理深挖:泛型推导的方向
TS 的泛型推导通常是从参数推:
function first<T>(arr: T[]): T { return arr[0] }
const x = first([1, 2, 3]) // T 被推为 number
但有几个反直觉的点:
// 1) 字面量类型默认会被"放宽"
function id<T>(x: T): T { return x }
const a = id('hello') // 推为 string,不是 'hello'
// 加 const 修饰才保留字面量
function id2<const T>(x: T): T { return x }
const b = id2('hello') // 推为 'hello'
// 2) 多个参数推不一致时会找"最佳公共类型"
function pair<T>(a: T, b: T): [T, T] { return [a, b] }
const p = pair('a', 1) // ❌ 通常报错;少数场景推为 string | number
// 3) 泛型默认值
interface Box<T = string> { value: T }
const b1: Box = { value: 'hi' } // T 默认是 string
Java 对照:Java 的泛型推导能力比 TS 弱不少(菱形操作符 <> 之外推导有限),TS 在"类型参数从参数列表反推"上做得更激进。
实战中:写库时显式给泛型默认值(<T = unknown>)能让用户少写一层;调用 API 时如果 TS 推不出来,手动指定 <T> 比"硬塞 as" 更安全。
18. 原理深挖:声明文件 .d.ts = 类型版的 JAR Manifest
社区库(特别是老库)很多是纯 JS 写的,没类型。TS 通过 .d.ts 文件单独提供类型声明:
// some-lib.d.ts —— 只有类型,无实现
export declare function add(a: number, b: number): number
export interface Config { timeout: number }
@types/xxx 包就是社区维护的"给 xxx 库补类型声明"。安装:
pnpm add -D @types/lodash # lodash 没自带类型,靠 @types/lodash 补
Vite 项目里常见的两个 .d.ts:
// vite-env.d.ts —— Vite 注入的全局类型(import.meta.env 等)
/// <reference types="vite/client" />
// shims-vue.d.ts —— 让 TS 认识 .vue 文件
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
Java 对照:类似 Java 9 的 module-info.java 或 OSGi 的 manifest——只声明"我导出了什么类型",不含实现。区别是 TS 的声明文件可以只描述外部 JS。
小结:速查表
| 特性 | 一句话 |
|---|---|
string / number / boolean |
基础类型,全小写 |
let x: T[] = [] |
数组类型 |
string | number |
联合类型,JS 特有 |
interface |
定义对象/结构的契约 |
type |
类型别名,能写联合/元组/映射 |
| 结构型类型 | 长得像就是,不用显式 implements |
泛型 <T> |
和 Java 一样,但推导更强 |
字面量类型 'a' | 'b' |
替代大多数 enum |
Partial / Pick / Omit |
工具类型,操作已有类型 |
as / 类型守卫 |
告诉 TS 真实类型,少用断言多用守卫 |
| 类型擦除 | 编译完只剩 JS,运行时拿不到类型 |
| 协变 / 逆变 | 数组/返回值协变;函数参数逆变 |
条件类型 + infer |
类型层面的 if-else + 解构 |
映射类型 [K in keyof T] |
类型层面的 for 循环 |
.d.ts |
给纯 JS 库补类型,类似 Java 的 module-info |
延伸阅读
- TypeScript 官方手册(中文) — 权威,建议从 Everyday Types 章节开始
- TypeScript Deep Dive — 免费好书,讲到原理层
- Type Challenges — 类型体操练习题,想玩花的再看
- Vue 3 + TypeScript 指南 — 下一步直接切到这里
下一篇:包管理与前端工程化(pnpm、package.json、node_modules 机制)