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]

注意


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 接口的关键差异

interface vs type 别名

两者在大多数场景可以互换:

interface User { name: string }
type User2 = { name: string }

选择规则:


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 对照

这像什么?像 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 对照


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 对照


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'>

读懂的关键:

  1. NoteMeta 是笔记头部 Frontmatter 的形状(纯数据)
  2. Note extends NoteMetainterface 里就是继承,Note 包含 NoteMeta 的所有字段再加三个自己的
  3. 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 运行时没有任何提示——错就错了

关键推论——很多人没意识到:

  1. as 断言不做任何运行时检查const u = data as User 哪怕 data 是 null,编译过、运行炸
  2. 类型守卫必须用运行时能验证的特征typeof / instanceof / 'name' in obj 这些能在 JS 里跑的判断,不能用 TS 类型本身判断
  3. 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)

直觉理解

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

延伸阅读


下一篇:包管理与前端工程化(pnpm、package.json、node_modules 机制)