TypeScript 入门第一周完整记录:知识点总结 + 踩坑实录
「Vue前端转全栈实战」系列第三篇。这周执行 12 个月学习计划的第一阶段,用五天把 TypeScript 核心语法过了一遍。这篇把所有知识点、练习代码、踩过的坑全部记录下来------不只是总结,是完整的学习过程还原。
目录
- 学习环境搭建
- [Day1:基础类型 + 类型推断](#Day1:基础类型 + 类型推断 "#day1%E5%9F%BA%E7%A1%80%E7%B1%BB%E5%9E%8B--%E7%B1%BB%E5%9E%8B%E6%8E%A8%E6%96%AD")
- 知识点
- [today's 踩坑](#today's 踩坑 "#%E4%BB%8A%E5%A4%A9%E7%9A%84%E8%B8%A9%E5%9D%91")
- [Day2:Interface + Type](#Day2:Interface + Type "#day2interface--type")
- [Day3:联合类型 + 类型收窄](#Day3:联合类型 + 类型收窄 "#day3%E8%81%94%E5%90%88%E7%B1%BB%E5%9E%8B--%E7%B1%BB%E5%9E%8B%E6%94%B6%E7%AA%84")
- [Day4:函数类型 + 泛型](#Day4:函数类型 + 泛型 "#day4%E5%87%BD%E6%95%B0%E7%B1%BB%E5%9E%8B--%E6%B3%9B%E5%9E%8B")
- Day5:工具类型
- 项目目前的代码结构
- 第一周总结
- 下周计划
学习环境搭建
创建项目
用 pnpm 创建 Vue3 + TypeScript 项目:
bash
pnpm create vite@latest mail-client -- --template vue-ts
cd mail-client
pnpm install
code .
第一个坑:Node.js 版本不兼容
运行命令直接报错:
bash
warn: This version of pnpm requires at least Node.js v22.13
warn: The current version of Node.js is v20.18.0
Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite
原因是 pnpm 最新版要求 Node.js v22 以上,本地还是 v20。解决方式是去 nodejs.org 下载最新 LTS 版本(v22.x)覆盖安装,升级完重新运行就好了。
项目结构
Vite 生成的项目比预期多几个文件,说明一下每个的作用:
css
mail-client/
src/
types/ ← 手动新建,放类型定义
composables/ ← 手动新建,放 Composable 函数
components/ ← 放 Vue 组件
views/ ← 放页面
App.vue
main.ts
tsconfig.json ← 主配置,引用下面两个
tsconfig.app.json ← 针对 src 源码的 TS 配置
tsconfig.node.json ← 针对 vite.config.ts 的 TS 配置
pnpm-lock.yaml ← pnpm 自动生成,不要手动改
vite.config.ts ← Vite 构建配置
tsconfig 配置
在 tsconfig.app.json 的 compilerOptions 里加上严格模式:
jsonc
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
strict: true 在继承的基础配置里其实已经开启了,手动写出来是为了让配置更直观。
Day1:基础类型 + 类型推断
知识点
八种基础类型:
typescript
const name: string = 'Tom'
const age: number = 32
const isActive: boolean = true
const nothing: null = null
const notDefined: undefined = undefined
// 数组类型(两种写法等价)
const list1: string[] = ['a', 'b', 'c']
const list2: Array<string> = ['a', 'b', 'c']
类型推断(重点):
大多数时候不需要手动标注,TS 自动推断:
typescript
const sender = 'boss@company.com' // 自动推断 string
const timestamp = new Date() // 自动推断 Date
const count = ref(0) // 自动推断 Ref<number>
// 什么时候需要手动标注?初始值是 null 的时候
const user = ref<User | null>(null) // 必须手动标注
any / unknown / never 的区别(重点):
typescript
// any:跳过类型检查,危险
let data: any = '123'
data.foo.bar // 不报错,但运行时崩溃
// unknown:不确定类型,但强制先判断再使用(安全)
let input: unknown = getUserInput()
if (typeof input === 'string') {
input.toUpperCase() // 收窄后才能用
}
// never:永远不会发生的类型
// 场景一:函数永远不返回
function throwError(msg: string): never {
throw new Error(msg)
}
// 场景二:穷举检查,漏掉某个 case 时报错
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'
function getLabel(status: MailStatus): string {
switch (status) {
case 'inbox': return '收件箱'
case 'sent': return '已发送'
case 'draft': return '草稿箱'
case 'trash': return '垃圾箱'
default:
const exhausted: never = status // 全覆盖则不报错
return exhausted
}
}
三者对比:
| any | unknown | never | |
|---|---|---|---|
| 能赋什么值 | 任何值 | 任何值 | 没有值 |
| 能直接使用 | ✅ | ❌ 先判断 | --- |
| 安全性 | 最低 | 高 | --- |
| 使用场景 | 尽量不用 | 类型不确定时 | 不可能执行到时 |
今天的踩坑
坑:noUnusedLocals 导致满屏报红
定义了一堆练习变量,VSCode 全部报红「已经声明定义但未使用」。
原因是 tsconfig 里的 noUnusedLocals: true------声明了但没有使用的变量会报错。
解决:在文件底部加 console.log 引用所有变量:
typescript
console.log(name, age, isActive, sender, timestamp...)
这个配置本身是好习惯,真实项目里帮你发现冗余代码。
Day2:Interface + Type
知识点
Interface 基础写法:
typescript
/**
* 功能:邮件数据模型
* 场景:收件箱、发件箱、草稿箱的邮件数据结构
*/
interface Mail {
id: number
subject: string
from: string
to: string[]
body: string
isRead: boolean
createdAt: Date
attachments?: string[] // ? 表示可选
readonly owner: string // readonly 表示只读
}
Interface 继承(重点):
typescript
/**
* 功能:所有数据模型的基础结构
* 场景:统一管理 id 和时间字段,避免重复定义
* 新增字段只改这一处,所有继承它的类型自动更新
*/
interface BaseItem {
id: number
createdAt: Date
updatedAt?: Date
}
// Mail 继承 BaseItem,不需要重复写 id 和 createdAt
interface MailV2 extends BaseItem {
subject: string
from: string
to: string[]
body: string
isRead: boolean
attachments?: string[]
}
// Contact 也继承 BaseItem
interface Contact extends BaseItem {
name: string
email: string
avatar?: string
isFavorite: boolean
}
为什么要用继承?
不用继承,每个 interface 都要重复写 id、createdAt、updatedAt。某天后端说「所有数据都要加 isDeleted 软删除字段」,要改 N 个地方,漏改一个就出 bug。用继承只改 BaseItem 一处,所有类型自动更新。这就是「单一数据源」的思想。
Type 写法:
typescript
// 联合类型(interface 做不到)
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'
type Priority = 'high' | 'normal' | 'low'
// 函数类型
type MailFilter = (mail: MailV2) => boolean
type EventHandler = (event: MouseEvent) => void
Interface vs Type 选择原则:
typescript
// 定义对象结构 → interface
interface UserCardProps {
name: string
avatar?: string
}
// 定义联合类型 → type(interface 做不到)
type ButtonVariant = 'primary' | 'secondary' | 'ghost'
Interface 和 Java class 的区别:
| Java class | TS interface | TS class | |
|---|---|---|---|
| 能 new | ✅ | ❌ | ✅ |
| 运行时存在 | ✅ | ❌ 编译后消失 | ✅ |
| 只描述结构 | ❌ | ✅ | ❌ |
| 有方法实现 | ✅ | ❌ | ✅ |
TS 的 interface 是纯类型定义,编译成 JS 后完全消失,不能 new。
Type 和 const 的区别:
typescript
// const 是「装数据的盒子」,运行时真实存在
const mailStatus = 'inbox' // 值固定是 'inbox'
// type 是「贴在盒子上的标签」,运行时不存在
type MailStatus = 'inbox' | 'sent' | 'draft' | 'trash'
// type 用来约束变量可以是哪些值
const current: MailStatus = 'inbox' // ✅
const wrong: MailStatus = 'deleted' // ❌ 不在联合类型里,报错
今天的踩坑
坑一:缺少必填字段
创建 testContact 对象时漏掉了 name 字段,报错:
arduino
类型中缺少属性 "name",但类型 "Contact" 中需要该属性
这是 ts(2741) 错误,固定含义是「对象缺少某个必填字段」,补上就解决了。
坑二:updatedAt 忘记加 ?
BaseItem 里的 updatedAt 没有加 ?,导致所有继承它的对象都必须传这个字段,报同样的错。加上 ? 变成可选字段就解决了。
Day3:联合类型 + 类型收窄
知识点
字面量联合类型:
typescript
type RequestStatus = 'idle' | 'loading' | 'success' | 'error'
type SortOrder = 'asc' | 'desc'
// 泛型 + 联合类型:通用 API 响应结构
interface ApiResponse<T> {
data: T | null
error: string | null
status: RequestStatus
}
可选链 ?.:
typescript
const user: User | null = getUser()
// 直接访问会报错
// user.name // ❌
// 可选链:为 null 时返回 undefined,不报错
const name = user?.name // string | undefined
const email = user?.email // string | undefined
空值合并 ?? vs 逻辑或 ||:
typescript
// || 遇到所有「假值」都用默认值(0、''、false 都算假值)
// || 遇到所有「假值」都会用默认值,?? 只有遇到 null 和 undefined 才用默认值。
// 假值包括:null、undefined、0、''、false、NaN
const pageSize = config.pageSize || 20 // pageSize 是 0 时返回 20,用户设置失效!
// ?? 只有遇到 null 或 undefined 才用默认值
const pageSize = config.pageSize ?? 20 // pageSize 是 0 时返回 0,正确
// 判断原则:
// 0、空字符串、false 对你有意义 → 用 ??
// 纯字符串显示、用户名等不应该为空 → 用 || 也没问题
// 实际项目里默认用 ??,更安全
非空断言 !. vs 可选链 ?.:
typescript
const user: User | null = getUser()
// ?. 安全:为 null 时返回 undefined
const name = user?.name
// !. 危险:强制告诉 TS「我保证不是 null」
// 如果真的是 null,运行时直接崩溃
const name = user!.name
// 实际项目里尽量少用 !.,用 ?. 配合 ?? 更安全
const name = user?.name ?? '匿名用户'
类型收窄:
typescript
// typeof 收窄基础类型
function getMailInfo(id: string | number) {
if (typeof id === 'string') {
return id.toUpperCase() // 这里 TS 知道是 string
}
return id.toFixed(0) // 这里 TS 知道是 number
}
// instanceof 收窄对象类型
catch (e: unknown) {
if (e instanceof Error) {
console.log(e.message) // 收窄后才能安全访问
}
}
// null 判断收窄
function handleResponse(response: ApiResponse<MailV2>) {
if (response.data !== null) {
console.log(response.data.subject) // 收窄后安全访问
}
}
typeof vs instanceof:
typescript
// typeof:判断基础类型
typeof 'hello' === 'string' // true
typeof 123 === 'number' // true
// instanceof:判断对象是哪个类创建的
new Date() instanceof Date // true
new Error() instanceof Error // true
[] instanceof Array // true
// typeof 判断对象只返回 'object',没法细分
typeof new Date() === 'object' // true,但不知道是 Date 还是别的
今天的踩坑
坑:const 赋值 null 导致类型变成 never
这是这周踩得最深的坑:
typescript
const maybeContact: Contact | null = null
const contactName = maybeContact?.name // 报错!
// 类型"never"上不存在属性"name"
明明标注了 Contact | null,为什么变成了 never?
原因:const 声明的变量值不可变,TS 看到直接赋值了 null,就把类型收窄成了 null------它知道这个变量永远只会是 null,Contact 那条路永远走不到,所以联合类型里的 Contact 被排除了,剩下就是 never。
解决:用函数返回值模拟,函数返回值 TS 不会提前收窄:
typescript
// ❌ const 直接赋 null,类型被收窄成 never
const maybeContact: Contact | null = null
// ✅ 用函数返回值,类型保持 Contact | null
function findContact(id: number): Contact | null {
return null
}
const maybeContact = findContact(999) // 类型是 Contact | null
const contactName = maybeContact?.name // ✅ 正常
Day4:函数类型 + 泛型
知识点
函数类型标注:
typescript
/**
* 功能:格式化邮件日期
* 场景:收件箱列表每封邮件右侧显示日期,如「2025/1/1」
*/
function formatMailDate(date: Date): string {
return date.toLocaleDateString('zh-CN')
}
/**
* 功能:统计未读邮件数量
* 场景:左侧导航栏「收件箱」旁边显示未读数量
*/
function countUnread(mails: MailV2[]): number {
return mails.filter(mail => !mail.isRead).length
}
// 返回 void:没有返回值
function markAsRead(mail: MailV2): void {
mail.isRead = true
}
函数类型作为参数(回调函数):
typescript
// 定义函数类型
type MailFilter = (mail: MailV2) => boolean
/**
* 功能:筛选邮件列表
* 场景:收件箱工具栏的筛选功能,接受任意筛选条件函数
*/
function filterMails(mails: MailV2[], filter: MailFilter): MailV2[] {
return mails.filter(filter)
}
// 未读筛选
const filterUnread: MailFilter = (mail) => !mail.isRead
// 关键词筛选
const filterByKeyword: MailFilter = (mail) => mail.subject.includes('会议')
// 使用
filterMails(mails, filterUnread)
filterMails(mails, filterByKeyword)
// 这就是「把函数作为参数」的核心价值:把「遍历逻辑」和「判断条件」分开,遍历逻辑只写一次,判断条件可以无限扩展,互不影响。
// 这个设计模式在 Vue 项目里非常高频------组件只负责渲染,具体的业务逻辑通过函数参数传进来,组件本身不需要改
泛型(重点):
typescript
// 不用泛型:只能处理一种类型
function getFirstMail(items: MailV2[]): MailV2 | null {
return items[0] ?? null
}
// 用泛型:一个函数处理所有类型
// 而且返回值类型和输入类型自动关联
function getFirst<T>(items: T[]): T | null {
return items[0] ?? null
}
// 传 MailV2[],返回值自动是 MailV2 | null(不需要手动标注)
const mail = getFirst(mockMails)
// 传 Contact[],返回值自动是 Contact | null
const contact = getFirst(contacts)
泛型约束:
typescript
/**
* 功能:根据 id 查找列表元素
* 场景:点击邮件列表某一行,通过 id 找到完整数据
* T extends { id: number } 确保传入的数组元素一定有 id 字段
*/
function findById<T extends { id: number }>(items: T[], id: number): T | null {
return items.find(item => item.id === id) ?? null
}
// 故意传没有 id 字段的类型,立刻报错
// findById<{ name: string }>([{ name: 'test' }], 1) // ❌ 报错
泛型 Composable(最实际的应用):
typescript
// src/composables/useFetch.ts
import { ref } from 'vue'
/**
* 功能:通用 API 请求封装
* 场景:所有接口请求复用同一套逻辑(loading、error、data)
* 泛型 T 让返回的 data 自动有正确的类型
*/
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const loading = ref<boolean>(false)
const error = ref<string | null>(null)
async function execute() {
loading.value = true
error.value = null
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`请求失败: ${res.status}`)
data.value = await res.json() as T
} catch (e: unknown) {
// 用 unknown 而不是 any,强制先判断类型再使用
if (e instanceof Error) {
error.value = e.message
}
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
// 使用时指定类型,data 自动有正确的类型
const { data: mailData } = useFetch<MailV2[]>('/api/mails')
// mailData.value 的类型是 MailV2[] | null,不需要手动标注
catch 里用 unknown 而不是 any 的原因:
JS 里可以 throw 任何东西,不只是 Error 对象:
typescript
throw new Error('标准错误')
throw '字符串也能 throw'
throw 404
throw { code: 'NETWORK_ERROR' }
所以 catch 到的 e 真的可能是任何类型,用 unknown 强制你先判断,比 any 假装没问题要安全得多。
今天的踩坑
坑:useFetch 写在 .ts 文件里报错
把 useFetch 写在 mail.ts 里,报错:
arduino
找不到名称"ref"
原因:mail.ts 是纯 TypeScript 文件,不是 Vue 组件,ref 需要从 vue 导入,但纯 .ts 文件在 Node.js 环境下无法使用 Vue 的响应式 API。
解决:import { ref } from 'vue'
Day5:工具类型
知识点
六个最常用的工具类型:
typescript
interface MailV2 extends BaseItem {
subject: string
from: string
to: string[]
body: string
isRead: boolean
attachments?: string[]
}
Omit:排除指定字段(要去掉的字段少时用)
typescript
/**
* 功能:创建邮件的表单类型
* 场景:写新邮件时不需要 id 和时间(后端自动生成)
*/
type CreateMail = Omit<MailV2, 'id' | 'createdAt' | 'updatedAt'>
Partial:所有字段变可选(编辑表单常用)
typescript
/**
* 功能:编辑邮件的表单类型
* 场景:编辑草稿时只传修改的字段,其他保持原样
*/
type EditMail = Partial<MailV2>
function editMail(original: MailV2, changes: EditMail): MailV2 {
return {
...original, // 保留原始数据
...changes, // 只覆盖传入的字段
updatedAt: new Date()
}
}
// 只改主题,其他字段不传
editMail(testMailV2, { subject: '新主题' })
Pick:只保留指定字段(要保留的字段少时用)
typescript
/**
* 功能:邮件列表预览类型
* 场景:收件箱列表只展示部分字段,不传 body 等大字段
*/
type MailPreview = Pick<MailV2, 'id' | 'subject' | 'from' | 'isRead' | 'createdAt'>
Omit vs Pick 怎么选:
- 要去掉的字段少 → 用
Omit - 要保留的字段少 → 用
Pick Omit会自动继承原类型新增的字段,Pick不会
Record:键值对字典类型
typescript
/**
* 功能:文件夹未读数量映射
* 场景:左侧导航栏显示每个文件夹的未读数量
*/
type FolderUnreadCount = Record<MailStatus, number>
// 等价于:{ inbox: number; sent: number; draft: number; trash: number }
const unreadCount: FolderUnreadCount = {
inbox: 3,
sent: 0,
draft: 2,
trash: 0
}
ReturnType:获取函数返回值类型
typescript
// 复用函数的返回类型,不需要手动重复写
/**
* 功能:获取函数返回值类型
* 场景:复用 findById 的返回类型,不需要手动重复写
*/
type FindByIdReturn = ReturnType<typeof findById>
// 等价于:MailV2 | null
Partial 的三个典型场景:
typescript
// Partial<T> 就是给 T 的所有字段加上 ?,最常用在编辑表单、搜索条件、配置项这三个场景------这些场景的共同特点是「不需要填写所有字段,填哪个算哪个」。
// 场景一:编辑表单(最常用)
type EditMail = Partial<MailV2>
// 场景二:搜索条件(条件可选填)
type MailSearchParams = Partial<{
keyword: string
from: string
isRead: boolean
startDate: Date
}>
// 场景三:配置项初始化(有默认值)
function initConfig(userConfig: Partial<MailClientConfig>): MailClientConfig {
return { ...defaultConfig, ...userConfig }
}
Required
Partial 和 Required 是一对反义词:
typescript
// Partial:所有字段变可选
type EditMail = Partial<MailV2> // 所有字段加 ?
// Required:所有字段变必填
type StrictMail = Required<MailV2> // 所有字段去掉 ?
今天的踩坑
坑:拼写错误但 TS 立刻提示
写邮件文件夹统计时,'inbox' 拼成了 'index':
typescript
const folderMap: Record<number, MailStatus> = {
[testMailV2.id]: 'index', // 拼错了
}
TS 立刻报错:
arduino
不能将类型 "index" 分配给类型 "MailStatus"
如果用纯 JS,'index' 完全不会有任何提示,运行时统计数据悄悄出错,排查起来非常麻烦。TS 在编译阶段就把这个问题暴露出来了------这就是 TypeScript 最核心的价值:把运行时才能发现的错误提前到编译阶段发现。
项目目前的代码结构
所有练习代码都写在 mail-client 项目里,对应真实的邮件客户端功能:
css
src/
types/
mail.ts ← 所有类型定义
composables/
useFetch.ts ← 泛型请求封装
类型和功能的对应关系:
| 类型 | 工具类型 | 对应功能 |
|---|---|---|
CreateMail |
Omit |
写邮件表单,去掉 id 和时间 |
EditMail |
Partial |
编辑草稿,所有字段可选 |
MailPreview |
Pick |
收件箱列表,只取必要字段 |
FolderUnreadCount |
Record |
导航栏未读数量统计 |
ApiResponse<T> |
泛型 | 所有 API 请求的通用响应结构 |
useFetch<T> |
泛型 | 类型安全的通用请求 Composable |
第一周总结
五天学完 TypeScript 核心语法,用一句话总结这周最大的感受:
TS 不是在给自己找麻烦,而是在提前暴露问题。
每一次报红,都是编译器在替你发现潜在的 bug。习惯了之后看到报红反而有安全感------比起运行时崩溃,编译时报错的修复成本低太多了。
这周掌握的知识点自检:
- interface vs type 怎么选
- any / unknown / never 的区别和使用场景
- 可选链 ?. 和非空断言 !. 的区别
- ?? 和 || 的区别,什么时候用哪个
- 类型收窄:typeof / instanceof / null 判断
- 泛型
<T>的概念:输入输出类型自动关联 - 六个工具类型:Partial / Required / Pick / Omit / Record / ReturnType
下周计划
进入第二阶段:Pinia 状态管理 + Vite 工程化配置
- 用 Pinia 建
mailStore,管理收件箱列表、未读数量、当前选中邮件 - 配置 Vite 路径别名、环境变量、构建分析
- 把本周定义的类型用到真实的 Vue 组件里
「Vue前端转全栈实战」系列持续更新,欢迎关注。 有问题欢迎评论区交流,我会把遇到的问题和解决过程都记录下来。