关于typescript:真正类型安全的-Web-Apps

37次阅读

共计 9341 个字符,预计需要花费 24 分钟才能阅读完成。

TypeScript 是 Web 行业一个重要的组成部分。这是有充沛理由证实的,它十分的棒。当然了,我说的不仅仅是上面这样

function add(a: number, b: number) {return a + b}
add(1, 2) // 类型查看通过了
add('one', 3) // 类型查看没通过

这十分的酷~但我想说的是相似上面这样的:

贯通整个程序的「类型」(包含了前端与后端)。在事实中它可能就是这样的,而且可能在将来的某一天你须要做出一个十分艰巨的决定:将 残余座位 这个字段拆分成 总座位 已坐座位 两个字段。如果没有「类型」来领导重构,那么你将会十分艰难。当然了,我也十分心愿你有一些很牢靠的单元测试。

这篇文章我并不是想压服你 JavaScript 中的「类型」是如许好。而是想跟你聊聊「端到端类型平安」有如许的棒,并且跟你介绍如何将它利用到你的我的项目中去。

首先,我这里说的「端到端类型平安」指的是,从数据库层到后端代码层再到前端 UI 层的全链路类型平安。然而我意识到,每个人的环境状况都是不同的。你可能没有操作数据库的权限。当年我在某互联网大厂工作,我常常生产许多来自不同后端团队的服务。我从未间接操作过数据库。所以如果要实现真正的「端到端类型平安」,可能是须要多个团队配合的。但心愿我能帮忙你走上正确的轨道,尽可能地适应你本人的状况。

让咱们我的项目的「端到端类型平安」变得艰难的最重要因素是:边界

要实现类型平安的 Web Apps 就是要笼罩边界的类型

在 Web 环境中,咱们有很多的边界。有一些你可能比较清楚,有一些你可能没有意识到。这里有一些你可能会在 Web 环境中遇到的边界的例子:

// 获取 localStorge 中 ticket 的值
const ticketData = JSON.parse(localStorage.get('ticket'))
// 它是 any 类型吗 😱

// 从 form 表单中获取值
// <form>
//   ...
//   <input type="date" name="workshop-date" />
//   ...
// </form>
const workshopDate = form.elements.namedItem('workshop-date')
// 它是 Element | RadioNodeList | null 😵 这样的类型吗

// 从 API 中获取数据
const data = await fetch('/api/workshops').then(r => r.json())
// 它是 any 类型吗 😭

// 获取配置信息或者路由上的参数(比方 Remix 或者 React Router)const {workshopId} = useParams()
// string | undefined 🥴

// 通过 node.js 的 fs 模块,读取或者解析字符串
const workshops = YAML.parse(await fs.readFile('./workshops.yml'))
// 它是 any 类型吗 🤔

// 从数据库中读取数据
const data = await SQL`select * from workshops`
// 它是 any 类型吗 😬

// 从申请中读取数据
const description = formData.get('description')
// FormDataEntryValue | null 🧐

还有更多示例,但这些是你会遇到的一些常见的边界:

  1. 本地存储
  2. 用户输出
  3. 网络
  4. 根底配置或约定
  5. 文件系统
  6. 数据库申请

事实上,不能 100% 确定咱们从边界获取到的内容就是咱们预期的内容。重要的事件说三遍: 不能,不能,不能。当然了,你能够应用 as Workshop 这样的显示类型申明来让 TypeScript 编译通过并失常运行,但这只是把问题给暗藏起来。文件可能被其余的过程批改,API 可能被批改,用户可能手动批改 DOM。所以咱们无奈明确的晓得边界的批改后果是否跟你预期的一样的。

然而,你是 可能 做一些事件来躲避一些危险的。比如说:

  1. 编写「类型守卫函数」或者「类型断言函数」
  2. 应用能够类型生成的工具(能给你 90% 的信念)
  3. 告诉 TypeScript 你的约定 / 配置

当初,让咱们看看应用这些策略通过 Web 利用的边界来实现端对端类型平安

类型守卫 / 断言函数

这的确是最无效的办法来依照你的预期解决边界类型问题。你能够通过写代码一一字段去查看它!这里有一个简略的类型守卫例子:

const {workshopId} = useParams()
if (workshopId) {// 你曾经获取了 workshopId 并且 TypeScript 也晓得了} else {// 解决你获取不到 workshopId 的状况}

在这个时候,有些人可能会因为要迁就 TypeScript 编译器而感到恼火。如果你非常必定 workshopId 是你必须要获取的字段,那么你能够在获取不到的时候间接抛出谬误(这样将对你的程序有十分大的帮忙而不是疏忽这些潜在的问题)

const {workshopId} = useParams()

if (!workshipId) {throw new Error('workshopId 不非法')
}

上面这个工具,我在我的项目中用的最多,因为它非常的便当,也让代码可读性更强

import invariant from 'tiny-invariant'

const {workshopId} = useParams()
invariant(workshopId, 'workshopId 不非法')

tiny-invariant 的 README 中提到

invariant 这个函数校验入参,如果入参为 false 则该函数会抛出谬误;为 true 则不会抛出

须要增加额定代码来进行校验总是比拟好受的。这是一个辣手的问题因为 TypeScript 不晓得你的约定和配置。也就是说,如果能让 TypeScript 晓得咱们我的项目中的约定和配置,那么将可能起到肯定的作用。这里有一些我的项目正在解决这样的问题:

  • routes-genremix-routes 都能够基于你的 Remix 约定或者配置主动生成类型(这块在本文还会再细说)
  • TanStack Router 会确保所有的工具办法(比方 useParams)都能够拜访到你定义的路由信息(无效地将你的配置告诉 TypeScript,这是咱们的另一种解决办法)

这个只是一个 URL 边界相干的例子,但这里对于如何教会 TypeScript 晓得咱们我的项目约定的计划是能够移植到其余边界状况的。

让咱们再来看看另一个更加简单的「类型守卫」的例子

type Ticket = {
  workshopId: string
  attendeeId: string
  discountCode?: string
}

// 类型守卫函数
function isTicket(ticket: unknown): ticket is Ticket {
  return (Boolean(ticket) &&
    typeof ticket === 'object' &&
    typeof (ticket as Ticket).workshopId === 'string' &&
    typeof (ticket as Ticket).attendeeId === 'string' &&
    (typeof (ticket as Ticket).discountCode === 'string' ||
      (ticket as Ticket).discountCode === undefined)
  )
}

// ticket 是 any 类型??const ticket = JSON.parse(localStorage.get('ticket'))

if (isTicket(ticket)) {// 咱们晓得 ticket 的类型了} else {// 解决获取不到 ticket 的状况 ....}

即使是一个绝对简略的类型,咱们如同都须要做不少的工作。设想一下在实在我的项目中更加简单的类型!!如果你常常要做这样相似的工作,那倡议你还是选用一些比拟好用的工具比方 zod 这样的。

import {z} from "zod"

const Ticket = z.object({workshopId: z.string(),
  attendeeId: z.string(),
  discountCode: z.string().optional()
})
type Ticket = z.infer<typeof Ticket>

const rawTicket = JSON.parse(localStorage.get('ticket'))
const result = Ticket.safeParse(rawTicket);
if (result.success) {
  const ticket = result.data
  //    ^? Ticket 数据
} else {// result.error 将会返回一个带有错误信息的 error 对象}

我对于 zod 最大的关怀点在于打包后的 bundle 体积比拟大(目前在没压缩的状况下有 42 KB 左右),所以我不常常在我的项目中应用到它。然而如果你只是在服务端应用到或者你真的从 zod 中失去很多的便当,那我感觉还是值得应用的。

tRPC 就通过 zod 实现了类型全笼罩;它在服务端和客户端共享类型来实现网络边界的类型平安。我集体喜爱应用 Remix 所以很少用到 tRPC;如果不应用 Remix,我 100 % 会应用 tRPC 来实现类型平安这样的能力。

类型守卫 / 断言函数同样也是你解决表单的FormData 的办法。对我来说,我十分喜爱应用 remix-validity-state,起因是:代码通过在运行时查看类型来保障整个利用的类型平安。

类型生成

下面曾经讲了一些对于如何为 Remix 约定路由生成类型的工具;类型生成可能解决端对端的类型平安问题。另一个风行的例子是 Prisma(我最喜爱的 ORM)。许多的 GraphQL 工具同样也有相似的能力。大抵的做法就是容许你去定义一个 schema,而后 Prisma 来保障你的数据库表跟这个 schema 是能够匹配上的。而后 Prisma 也会生成跟 schema 匹配的 TypeScript 类型申明。高效地放弃类型跟数据库同步。比方:

const workshop = await prisma.user.findFirst({// ^? { id: string, title: string, date: Date} 🎉
  where: {id: workshopId},
  select: {id: true, title: true, date: true},
})

任何时候你批改了 schema 并且创立一个 Prisma 的 migration,Prisma 将会间接更新你的 node_modules 目录下对应的类型文件。所以当你在应用 Prisma ORM 的时候,类型文件始终跟你的 schema 是保持一致的。上面是一个实在我的项目的 User 数据库表:

model User {id           String     @id @default(uuid())
  createdAt    DateTime   @default(now())
  updatedAt    DateTime   @updatedAt
  email        String     @unique(map: "User.email_unique")
  firstName    String
  discordId    String?
  convertKitId String?
  role         Role       @default(MEMBER)
  team         Team
  calls        Call[]
  sessions     Session[]
  postReads    PostRead[]}

这个是生成的类型

/**
 * Model User
 * 
 */
export type User = {
  id: string
  createdAt: Date
  updatedAt: Date
  email: string
  firstName: string
  discordId: string | null
  convertKitId: string | null
  role: Role
  team: Team
}

这的确是一个十分棒的开发体验,并它能够作为我在后端应用程序中类型的终点。

这里次要的危险在于,如果数据库的 schema 可能会跟数据库外面的数据因为某种原因导致不同步。然而我还没有在应用 Prisma 的过程中遇到过这种状况,心愿这种状况是很少见,所以我对不在数据库交互中增加断言函数还是很有信念的。然而,如果你没方法应用像 Prisma 这样的工具或者你所在的团队不负责数据库 schema,我还是倡议你去找其余办法生产基于数据库 schema 的类型,因为这切实是太棒了。

请记住,咱们不仅仅是为了服务 TypeScript。即便咱们的我的项目不应用 TypeScript,咱们也应该让利用边界之间的数据跟咱们预知类型的保持一致。

应用约定 / 配置来帮忙 TypeScript

另一个挑战比拟大的边界是网络边界。验证服务端给到 UI 层的数据是一件比拟艰难的事件。fetch 没有提供范型反对,即使是有,你也只是在自欺欺人。

// 这样不行, 别这么做 :
const data = fetch<Workshop>('/api/workshops/123').then(r => r.json())

请容许我给你说一些对于范型的机密,基本上大部分函数像上面这么做都是不好的抉择:

function getData<DataType>(one, two, three) {const data = doWhatever(one, two, three)
  return data as DataType // <-- 这里这里!!!}

任何时候你看到这个写法 as XXX 类型,你能够认为:这是在坑骗 TypeScript 的编译器。即便有的时候你为了可能让代码不报错而不得不这么做,我都仍然不倡议你像下面这个 getData 函数这样做。而这个时候,你有两个抉择:

const a = getData<MyType>() // 🤮 我十分好受
const b = getData() as MyType // 😅 好一点,然而我仍然好受

在这两种状况中,你都是在对 TypeScript 扯谎(也是在对本人扯谎),然而第一种状况你不晓得你在对本人扯谎。如果你不得不对本人扯谎或者决定对本人扯谎,起码你要晓得你正在这么做。

所以咱们应该这么样做能力不对本人说谎呢?好的,你须要跟你 fetch 的数据建设一个强约定,而后再告诉 TypeScript 这个约定。看看 Remix 中是怎么做的,上面是一个简略的例子:

import type {LoaderArgs} from "@remix-run/node"
import {json} from "@remix-run/node"
import {useLoaderData} from "@remix-run/react"
import {prisma} from "~/db.server"
import invariant from "tiny-invariant"

export async function loader({params}: LoaderArgs) {const { workshopId} = params
  invariant(workshopId, "Missing workshopId")
  const workshop = await prisma.workshop.findFirst({where: { id: workshopId},
    select: {id: true, title: true, description: true, date: true},
  })
  if (!workshop) {
        // 会被 Remix 的谬误捕捉错处
    throw new Response("Not found", { status: 404})
  }
  return json({workshop})
}

export default function WorkshopRoute() {const { workshop} = useLoaderData<typeof loader>()
  //      ^? {title: string, description: string, date: string}
  return <div>{/* Workshop form */}</div>
}

useLoaderData 函数接管一个 Remix loader 函数类型并且可能确定 JSON 相应数据的所有可能。loader ** 函数运行在服务端,WorkshopRoute 函数运行在服务端和客户端,因为 userLoaderData 可能明确 Remix loader 的约定,所以类型能够在网络边界同步(共享)。Remix 会确保服务端 loader 的数据是通过 useLoaderData 最初返回的。所有的事件都在同一个文件外面实现,不须要 API 路由。

如果你还没有这样实际过,你也能够置信这是一个十分棒的体验。设想一下,咱们决定要在 UI 中显示 价格 字段。这就像在数据库查问更新一样简略,而后咱们忽然在咱们的 UI 代码中应用它,而无需更改任何其余内容。完完全全地类型平安!!!如果咱们决定不应用 description 这个字段,那么咱们只须要在 select 那里删除这个字段,而后咱们就会看到之前所有用到这个字段的中央都飘红了(类型查看报错)。这对于咱们重构代码十分有用。

无处不在的 网络边界

你可能曾经留神到了,即便 date 在后端是一个 Date 类型,它在咱们的 UI 层代码应用的却是 string 类型。这是因为数据通过了网络边界,在这个过程中所有数据都会被序列化成字符串(JSON 不反对 Date 类型)。类型工具强制让这种行为产生。

如何你打算要去显示日期,你可能须要在 loader 中格式化它,在它被发送到客户端之前做这个事件是为了避免出现时区错乱。如果你不喜爱这么做,你能够应用像 superjson 或者 remix-typedjson** 这样的工具让这些数据在发送到 UI 层的时候被复原成日期格局。

在 Remix 中,咱们也能够在 action 中保障类型平安。看看上面这个例子:

import type {ActionArgs} from "@remix-run/node"
import {redirect, json} from "@remix-run/node"
import {useActionData, useLoaderData,} from "@remix-run/react"
import type {ErrorMessages, FormValidations} from "remix-validity-state"
import {validateServerFormData,} from "remix-validity-state"
import {prisma} from "~/db.server"
import invariant from "tiny-invariant"

// loader 逻辑写在这里。。。已省略

const formValidations: FormValidations = {
  title: {
    required: true,
    minLength: 2,
    maxLength: 40,
  },
  description: {
    required: true,
    minLength: 2,
    maxLength: 1000,
  },
}

const errorMessages: ErrorMessages = {tooShort: (minLength, name) =>
    `The ${name} field must be at least ${minLength} characters`,
  tooLong: (maxLength, name) =>
    `The ${name} field must be less than ${maxLength} characters`,
}

export async function action({request, params}: ActionArgs) {const { workshopId} = params
  invariant(workshopId, "Missing workshopId")
  const formData = await request.formData()
  const serverFormInfo = await validateServerFormData(formData, formValidations)
  if (!serverFormInfo.valid) {return json({ serverFormInfo}, {status: 400})
  }
  const {submittedFormData} = serverFormInfo
  //      ^? {title: string, description: string}
  const {title, description} = submittedFormData
  const workshop = await prisma.workshop.update({where: { id: workshopId},
    data: {title, description},
    select: {id: true},
  })
  return redirect(`/workshops/${workshop.id}`)
}

export default function WorkshopRoute() {
  // ... loader 解决逻辑。。。已省略
  const actionData = useActionData<typeof action>()
  //    ^? {serverFormInfo: ServerFormInfo<FormValidations>} | undefined
  return <div>{/* Workshop form */}</div>
}

同样,不论咱们的 action 返回什么,最终都会返回被 useActionData 序列化之后的类型。在这种状况下,我会应用 remix-validity-state 来保障类型平安。因为我通过提供给 remix-validity-state 函数传递 schema 的模式进行校验,所以被提交的数据也同样是类型平安的。submittedFormData 也是类型平安的。当然,还有其余的库能够实现相似的能力,但重点是,咱们通过这些大量并且简略的工具就可能实现成果十分好的边界类型平安,同时也加强了咱们部署和运行代码的信念。显然,这些工具的 API 都比较简单易用,尽管有时候这些工具自身外部的实现是非常复杂的 😅

应该提到的是,这也实用于其余 Remix 工具。meta export 也能够是类型平安的,useFetcher,useMatcher 等等都能够。世界又变得无比美妙~~

认真的说,loader 只是冰山一角,但也能够阐明很多的问题了~让咱们再看看上面这个:

这大略就是网络边界类型平安吧。而且,这一切都在一个文件中实现,太酷了🔥

总结

我在这里要阐明的一点是,类型平安不仅有价值的,而且是能够做到端到端地跨边界实现。最初 loader 的例子笼罩了从数据库到 UI。数据类型平安从 数据库 node 浏览器,这让研发效率大大的晋升。不论你正在做什么我的项目,请思考如何缩小相似 as XXX 类型 这样的用法,通过我上述的一些倡议尝试将这样的用法转换成真正的类型平安。我想日后你会感激你本人的。这真的是值得投入去做的事件。

如果你想要运行一下这个例子,你能够间接 clone 这个我的项目:我的项目地址

最初的最初,心愿你能够在下方留言跟我一起探讨你的认识~~

欢送点赞,关注,珍藏 ❤️ ❤️ ❤️

也欢送关注我的 掘金 账号

正文完
 0