npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

api-patch-contract

v0.1.0

Published

Type-safe PATCH payload builder for forms and API contracts.

Readme

api-patch-contract

Type-safe PATCH payload builder for frontend forms and API contracts.

api-patch-contract helps you safely create API PATCH payloads from changed form/state data.

It is not just another object diff library. It answers a more practical question:

“Which changed fields am I allowed to send to my backend contract?”

const patch = createPatch(initialUser, currentUser, userPatchContract)

// {
//   name: 'Anna Smith',
//   profile: {
//     city: 'Tallinn'
//   }
// }

Why?

In real frontend apps, edit forms usually contain more data than the backend accepts.

type User = {
  id: string
  email: string
  name: string
  role: 'admin' | 'user'
  profile: {
    city: string
    phone: string | null
  }
  createdAt: string
}

But your API may only accept this:

type UpdateUserPayload = {
  name?: string
  role?: 'admin' | 'user'
  profile?: {
    city?: string
    phone?: string | null
  }
}

Without a helper, you often end up writing fragile code:

const payload: UpdateUserPayload = {}

if (form.name !== initial.name) {
  payload.name = form.name
}

if (form.profile.city !== initial.profile.city) {
  payload.profile ??= {}
  payload.profile.city = form.profile.city
}

api-patch-contract replaces that boilerplate with a typed contract.


Features

  • Type-safe patch contracts
  • Sends only changed fields
  • Prevents accidental fields from being sent
  • Nested object support
  • null / undefined semantics
  • Field transforms
  • Custom comparators
  • Dirty paths
  • Dirty field tree
  • Strict mode for contract drift detection
  • Array strategies:
    • replace array
    • compare array as set
    • diff array items by id
  • Zero runtime dependencies
  • Framework agnostic: works with Vue, Nuxt, React, Next, Svelte, Node.js

Installation

npm install api-patch-contract
pnpm add api-patch-contract
yarn add api-patch-contract

Quick start

import { createPatch, definePatchContract } from 'api-patch-contract'

type User = {
  id: string
  email: string
  name: string
  role: 'admin' | 'user'
  profile: {
    city: string
    phone: string | null
  }
  createdAt: string
}

const userPatchContract = definePatchContract<User>()({
  name: true,
  role: true,
  profile: {
    city: true,
    phone: true,
  },
})

const initialUser: User = {
  id: '1',
  email: '[email protected]',
  name: 'Anna',
  role: 'user',
  profile: {
    city: 'Riga',
    phone: null,
  },
  createdAt: '2026-01-01',
}

const currentUser: User = {
  ...initialUser,
  name: 'Anna Smith',
  profile: {
    ...initialUser.profile,
    city: 'Tallinn',
  },
}

const patch = createPatch(initialUser, currentUser, userPatchContract)

// patch:
// {
//   name: 'Anna Smith',
//   profile: {
//     city: 'Tallinn'
//   }
// }

Fields not included in the contract are ignored.

So id, email, and createdAt will never be sent by this contract.


Type inference

You can infer the payload type directly from a contract.

import type { InferPatchPayload } from 'api-patch-contract'

type UserPatchPayload = InferPatchPayload<typeof userPatchContract>

const payload: UserPatchPayload = {
  name: 'Anna Smith',
  profile: {
    phone: null,
  },
}

Trying to send a field that is not part of the contract will fail at compile time:

const invalidPayload: UserPatchPayload = {
  // @ts-expect-error id is not allowed by the contract
  id: '1',
}

Field transforms

Use field() when you need normalization or transformation before sending the payload.

import { field } from 'api-patch-contract'

const contract = definePatchContract<UserForm>()({
  name: field<string>({
    transform: value => value.trim(),
  }),
})
const patch = createPatch(
  { name: 'Anna' },
  { name: ' Anna Smith ' },
  contract,
)

// { name: 'Anna Smith' }

Empty string as null

This is useful when a backend treats null as “clear this field”.

const contract = definePatchContract<UserForm>()({
  phone: field<string | null>({
    emptyStringAsNull: true,
  }),
})
const patch = createPatch(
  { phone: '+100' },
  { phone: '' },
  contract,
)

// { phone: null }

Custom comparator

Use custom comparators for dates, rounded numbers, case-insensitive values, or domain-specific equality.

const contract = definePatchContract<EventForm>()({
  startDate: field<Date, string>({
    compare: (initial, current) =>
            initial.toISOString().slice(0, 10) === current.toISOString().slice(0, 10),
    transform: value => value.toISOString(),
  }),
})

Omitting values

By default, undefined is omitted from the resulting payload.

You can define extra omit rules:

const contract = definePatchContract<ProductForm>()({
  description: field<string>({
    omitIf: 'empty-string',
  }),

  tags: field<string[]>({
    omitIf: ['empty-array'],
  }),
})

Supported presets:

type OmitPreset =
  | 'undefined'
  | 'null'
  | 'empty-string'
  | 'empty-array'
  | 'empty-object'

You can also pass a function:

const contract = definePatchContract<ProductForm>()({
  price: field<number>({
    omitIf: value => value < 0,
  }),
})

Changed paths

import { getChangedPaths } from 'api-patch-contract'

const paths = getChangedPaths(initialUser, currentUser, userPatchContract)

// ['name', 'profile.city']

Dirty fields

import { getDirtyFields } from 'api-patch-contract'

const dirty = getDirtyFields(initialUser, currentUser, userPatchContract)

// {
//   name: true,
//   profile: {
//     city: true
//   }
// }

Useful for UI:

const result = createPatchResult(initialUser, currentUser, userPatchContract)

if (result.hasChanges) {
  await api.patch('/users/1', result.patch)
}

Strict mode

Strict mode throws when data changed outside the contract.

This helps catch contract drift and accidental frontend/backend mismatch.

const patch = createPatch(initialUser, currentUser, userPatchContract, {
  strict: true,
})

Example:

const contract = definePatchContract<User>()({
  name: true,
})

createPatch(
  initialUser,
  {
    ...initialUser,
    name: 'Anna Smith',
    email: '[email protected]',
  },
  contract,
  { strict: true },
)

// throws PatchContractError:
// Detected changed fields outside the patch contract: email

Array strategies

Arrays are hard. Different APIs expect different semantics.

api-patch-contract gives you explicit strategies.

arrayReplace()

Send the whole array when it changes.

import { arrayReplace } from 'api-patch-contract'

const contract = definePatchContract<ProductForm>()({
  images: arrayReplace<Image>(),
})
// if images changed:
// {
//   images: [...currentImages]
// }

arrayAsSet()

Compare array values as a set. Order does not matter.

import { arrayAsSet } from 'api-patch-contract'

const contract = definePatchContract<ProductForm>()({
  tags: arrayAsSet<string>(),
})
createPatch(
  { tags: ['vue', 'ts'] },
  { tags: ['ts', 'vue'] },
  contract,
)

// {}

You can compare object arrays by key:

const contract = definePatchContract<ProductForm>()({
  categories: arrayAsSet<Category>({
    getKey: 'id',
  }),
})

arrayById()

Create item-level array patches by id.

import { arrayById } from 'api-patch-contract'

type Skill = {
  id: string
  title: string
  level: number
}

const skillContract = definePatchContract<Skill>()({
  title: true,
  level: true,
})

const contract = definePatchContract<UserForm>()({
  skills: arrayById<Skill, 'id', 'items'>('id', {
    mode: 'items',
    itemContract: skillContract,
  }),
})

Result:

{
  skills: {
    added: [{ id: 'node', title: 'Node.js', level: 2 }],
    updated: [{ id: 'vue', level: 5 }],
    removed: ['ts']
  }
}

API reference

definePatchContract<T>()(contract)

Defines a type-safe patch contract.

const contract = definePatchContract<User>()({
  name: true,
})

createPatch(initial, current, contract, options?)

Creates a PATCH payload.

const patch = createPatch(initial, current, contract)

Options:

type CreatePatchOptions = {
  strict?: boolean
  omitUndefined?: boolean
}

createPatchResult(initial, current, contract, options?)

Returns patch + metadata.

const result = createPatchResult(initial, current, contract)

result.patch
result.changedPaths
result.dirtyFields
result.hasChanges

getChangedPaths(initial, current, contract)

Returns changed paths covered by the contract.

getDirtyFields(initial, current, contract)

Returns a nested dirty-field tree.

hasChanges(patch)

Checks whether a patch is non-empty.

hasChanges({}) // false
hasChanges({ name: 'Anna' }) // true

hasChanges(initial, current, contract)

Checks whether there are contract-covered changes.

isFieldChanged(initial, current, contract, path)

Checks a single changed path.

isFieldChanged(initial, current, contract, 'profile.city')

Vue example

import { computed, reactive } from 'vue'
import { createPatchResult, definePatchContract } from 'api-patch-contract'

const initialUser = structuredClone(userFromApi)

const form = reactive(structuredClone(userFromApi))

const contract = definePatchContract<User>()({
  name: true,
  profile: {
    city: true,
    phone: true,
  },
})

const patchResult = computed(() =>
  createPatchResult(initialUser, form, contract),
)

async function save() {
  if (!patchResult.value.hasChanges) return

  await $fetch(`/api/users/${form.id}`, {
    method: 'PATCH',
    body: patchResult.value.patch,
  })
}

React example

import { useMemo, useState } from 'react'
import { createPatchResult, definePatchContract } from 'api-patch-contract'

const contract = definePatchContract<User>()({
  name: true,
  profile: {
    city: true,
  },
})

function UserForm({ user }: { user: User }) {
  const [form, setForm] = useState(user)

  const patchResult = useMemo(
    () => createPatchResult(user, form, contract),
    [user, form],
  )

  return (
    <button disabled={!patchResult.hasChanges}>
      Save
    </button>
  )
}

Design goals

This package should be

  • small
  • predictable
  • strongly typed
  • framework-independent
  • explicit about API contracts
  • safe by default

This package should not be

  • a full form library
  • a validation library
  • a backend schema framework
  • a JSON Patch RFC implementation
  • a replacement for Zod, Valibot, VeeValidate, React Hook Form, etc.

How it differs from object diff libraries

Generic diff libraries usually answer:

What changed?

api-patch-contract answers:

What changed and is allowed to be sent to this API endpoint?

That is the key difference.


Recommended project scripts

npm run typecheck
npm run test
npm run build
npm run check

Contributing

Issues and pull requests are welcome.

Good first contribution ideas:

  • Add more recipes
  • Add array strategy examples
  • Improve type inference tests
  • Add framework-specific examples
  • Add benchmark tests

License

MIT