Opaque types
Treat rich values such as Dayjs or Decimal as single fields in TypeScript.
Forms often hold richer values than strings and numbers: a date backed by Day.js's Dayjs, a price backed by Decimal.js's Decimal, or a Luxon DateTime. React Hook Form's type helpers, such as Path, DeepPartial, and FieldErrors, walk every property of your form type to infer field paths. By default, that walk also goes into those classes: their internal properties and every method.
The problem
Given this form type:
import type { Dayjs } from "dayjs"import type Decimal from "decimal.js"type BookingValues = {guest: { name: string }stay: { checkIn: Dayjs; checkOut: Dayjs }deposit: Decimal}
Path<BookingValues> accepts "stay.checkIn.year" and "deposit.d", which are a Dayjs method and a Decimal internal. Those are never real form fields. Each class adds dozens of paths, and in a large or deeply nested form the extra recursion slows down editor type-checking. In the worst case, TypeScript reports "Type instantiation is excessively deep and possibly infinite".
Register the types once
Add the classes to the OpaqueTypes interface with declaration merging, in a .d.ts file that is part of your TypeScript program:
// react-hook-form.d.tsimport type { Dayjs } from "dayjs"import type Decimal from "decimal.js"declare module "react-hook-form" {interface OpaqueTypes {dayjs: Dayjsdecimal: Decimal}}
The keys (dayjs, decimal) are only labels. The registration is global, so it applies to every form in the project. Date, File, and FileList are already treated as single values and don't need registering.
What changes
With the registration in place, Path<BookingValues> is exactly "guest" | "guest.name" | "stay" | "stay.checkIn" | "stay.checkOut" | "deposit". Errors for these fields are typed as one FieldError, so errors.stay?.checkOut?.message works as for any other field.
Registration changes types only. At runtime, React Hook Form already stores class instances by reference, without copying or walking into them, so handleSubmit receives the same Dayjs and Decimal objects you set.
Use the values in a form
Rich values usually come from a controlled component such as a date picker, so connect them with Controller or useController. Validation functions receive the instance, so you can use its methods directly:
import dayjs, { type Dayjs } from "dayjs"import Decimal from "decimal.js"import { Controller, useForm } from "react-hook-form"type BookingValues = {guest: { name: string }stay: { checkIn: Dayjs; checkOut: Dayjs }deposit: Decimal}export default function BookingForm({onSubmit,}: {onSubmit: (values: BookingValues) => void}) {const today = dayjs().startOf("day")const {register,control,handleSubmit,formState: { errors },} = useForm<BookingValues>({defaultValues: {guest: { name: "" },stay: { checkIn: today, checkOut: today.add(1, "day") },deposit: new Decimal("0"),},})return (<form noValidate onSubmit={handleSubmit(onSubmit)}><label htmlFor="name">Name</label><inputid="name"{...register("guest.name", { required: "Enter a name." })}/><Controllercontrol={control}name="stay.checkOut"rules={{validate: (checkOut, values) =>checkOut.isAfter(values.stay.checkIn) ||"Check-out must be after check-in.",}}render={({ field }) => (<><label htmlFor="checkOut">Check-out</label><inputid="checkOut"type="date"ref={field.ref}name={field.name}onBlur={field.onBlur}value={field.value.format("YYYY-MM-DD")}onChange={(event) => field.onChange(dayjs(event.target.value))}/></>)}/>{errors.stay?.checkOut && (<p role="alert">{errors.stay.checkOut.message}</p>)}<button type="submit">Book</button></form>)}
Before sending the values to a server, convert them to a wire format, for example checkOut.format("YYYY-MM-DD") and deposit.toString(). JSON.stringify does not preserve class instances.
See the OpaqueTypes TypeScript reference for the type definition.