跳到主要内容
稻草人
プロフィール

EP.02

💻Technology

TypeScript的类型设计:让博客CMS更安全

ブログCMSのデータをTypeScriptで安全に扱うための型設計メモ。

BY 稻, 草人

很多项目虽然使用TypeScript,但类型设计仍然停留在“给对象加一个interface”。等到草稿、发布、定时发布、作者权限和SEO字段都加入后,同一个Post类型会被列表、详情、表单和API反复使用,最后到处都是可选属性和类型断言。

我以前也会把接口数据直接放进表单,保存时原样发回去。这样开始很快,但CMS的读取模型和写入模型其实不是一回事。

目次

不要让一个Post负责所有场景

type PostStatus = 'draft' | 'review' | 'scheduled' | 'published'

type Post = {
  id: number
  title: string
  slug: string
  content: string
  excerpt: string
  status: PostStatus
  publishedAt: string | null
  createdAt: string
  updatedAt: string
}

完整文章中一定存在的字段,不要为了省事全部加?。如果允许为空,用null明确表达。

单独设计表单与请求类型

type PostFormValues = {
  title: string
  slug: string
  content: string
  excerpt: string
  status: PostStatus
  categoryIds: number[]
  tagIds: number[]
  publishedAt: string | null
}

type CreatePostInput = PostFormValues

type UpdatePostInput = Partial<
  Pick<PostFormValues,
    'title' | 'slug' | 'content' | 'excerpt' |
    'status' | 'categoryIds' | 'tagIds' | 'publishedAt'
  >
>

只有后台确实支持部分更新时,才使用Partial。类型应该符合真实API,而不是为了让前端写起来方便。

用联合类型表达状态规则

定时发布必须有时间,草稿则不需要。可以把规则写进类型:

type PublicationInput =
  | { status: 'draft' | 'review'; publishedAt: null }
  | { status: 'scheduled'; publishedAt: string }
  | { status: 'published'; publishedAt: string }

这样能减少“状态为scheduled但时间为空”这种无效组合。

as Post不会验证API数据

const post = await response.json() as Post

这只是告诉编译器相信开发者,不会在运行时检查JSON。服务器字段改变、返回HTML错误页时,TypeScript无法阻止。

async function fetchPost(id: number): Promise<Post> {
  const response = await fetch(`/api/posts/${id}`)
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return response.json()
}

重要的系统边界可以使用Zod等运行时校验库。TypeScript负责静态检查,运行时校验负责不可信的外部数据,两者不能替代。

列表和详情分开

列表接口通常不返回完整正文,不应该仍然声明为Post[]。

type PostListItem = Pick<
  Post,
  'id' | 'title' | 'slug' | 'status' | 'publishedAt' | 'updatedAt'
>

当列表字段变化时,就不会影响文章详情和编辑表单。

外部CMS数据先转换

WordPress REST API中的标题和正文可能包含rendered字段:

type WordPressPostResponse = {
  id: number
  slug: string
  title: { rendered: string }
  content: { rendered: string }
  date: string
  modified: string
}

不要让所有组件都读取post.title.rendered,在API层统一转换。

function mapPost(source: WordPressPostResponse): PostViewModel {
  return {
    id: source.id,
    slug: source.slug,
    titleHtml: source.title.rendered,
    contentHtml: source.content.rendered,
    publishedAt: source.date,
    updatedAt: source.modified
  }
}

外部结构变化时,影响会集中在转换层。rendered是HTML,输出时还要确认内容来源和清理策略。

日期先当字符串处理

JSON里的日期是字符串,不会自动成为Date。

type PostResponse = {
  publishedAt: string | null
}

业务需要时,在边界处转换。还要约定UTC或日本时间,不能只靠字段名判断。

用unknown代替不必要的any

type PostMeta = Record<string, unknown>

const value = post.meta['readingTime']
if (typeof value === 'number') {
  console.log(value)
}

unknown会要求使用者先判断,比any更适合来源不确定的自定义字段。

检查清单

  • 读取模型和写入模型是否混用
  • null和可选属性是否有统一规则
  • 状态能否使用联合类型
  • 列表与详情是否需要同一组字段
  • API边界是否进行转换和校验
  • 日期格式与时区是否明确
  • 是否存在不必要的any和as
  • 外部CMS结构是否泄漏到所有组件
  • 更新接口是全量还是部分更新

总结

TypeScript并不是类型越复杂越好。真正有价值的类型设计,是让不应该出现的状态更难进入系统。

对于博客CMS,最好分清外部API响应、系统内部模型、编辑表单、创建与更新输入。代码会稍微增加,但接口变更、表单修改和问题调查都会更容易。

参考资料