
Ts Library
- 1.8k installs
- 696 repo stars
- Updated July 27, 2026
- onmax/nuxt-skills
ts-library is an agent skill for Use when authoring TypeScript libraries or npm packages - covers project setup, package.json exports, build tooling tsdo
About
The ts-library skill Use when authoring TypeScript libraries or npm packages - covers project setup, package.json exports, build tooling tsdown/unbuild , API design patterns, type inference tricks, testing, and publishing to npm. Use when bundling, configuring dual CJS/ESM output, or setting up release workflows. It covers starting a new TypeScript library single or monorepo. Key workflows include setting up package.json exports for dual CJS/ESM. - Starting a new TypeScript library single or monorepo - Setting up package.json exports for dual CJS/ESM - Configuring tsconfig for library development - Choosing build tools tsdown, unbuild - Designing type-safe APIs builder, factory, plugin patterns - Writing advanced TypeScript types - Setting up vitest for library testing - Configuring release workflow and CI For Nuxt module development: use Developers invoke ts-library when the task matches the triggers and reference files in SKILL.md for grounded, stepwise execution.
- Starting a new TypeScript library single or monorepo
- Setting up package.json exports for dual CJS/ESM
- Configuring tsconfig for library development
- Choosing build tools tsdown, unbuild
- Designing type-safe APIs builder, factory, plugin patterns
Ts Library by the numbers
- 1,810 all-time installs (skills.sh)
- +29 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #421 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
ts-library capabilities & compatibility
- Capabilities
- starting a new typescript library single or mono · setting up package.json exports for dual cjs/esm · configuring tsconfig for library development · choosing build tools tsdown, unbuild · designing type safe apis builder, factory, plugi
- Use cases
- documentation
What ts-library says it does
Patterns for authoring high-quality TypeScript libraries, extracted from studying unocss, shiki, unplugin, vite, vitest, vueuse, zod, trpc, drizzle-orm, and more.
npx skills add https://github.com/onmax/nuxt-skills --skill ts-libraryAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.8k |
|---|---|
| repo stars | ★ 696 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | onmax/nuxt-skills ↗ |
What problem does ts-library solve for developers using the documented workflows?
Use when authoring TypeScript libraries or npm packages - covers project setup, package.json exports, build tooling tsdown/unbuild , API design patterns, type inference tricks, testing, and publishing
Who is it for?
Developers working with ts-library patterns described in the skill documentation.
Skip if: Skip when docs are empty or the task is outside the skill documented scope.
When should I use this skill?
Use when Use when authoring TypeScript libraries or npm packages - covers project setup, package.json exports, build tooling tsdown/unbuild , API design patterns, type inference tricks, tes
What you get
Actionable ts-library guidance grounded in SKILL.md workflows and reference files.
- package.json exports map
- tsdown.config.ts
- Vitest test harness
By the numbers
- Bundles 10 on-demand reference files from setup through CI workflows
- Patterns extracted from 9 named ecosystems including vitest, zod, and trpc
Files
TypeScript Library Development
Patterns for authoring high-quality TypeScript libraries, extracted from studying unocss, shiki, unplugin, vite, vitest, vueuse, zod, trpc, drizzle-orm, and more.
When to Use
- Starting a new TypeScript library (single or monorepo)
- Setting up package.json exports for dual CJS/ESM
- Configuring tsconfig for library development
- Choosing build tools (tsdown, unbuild)
- Designing type-safe APIs (builder, factory, plugin patterns)
- Writing advanced TypeScript types
- Setting up vitest for library testing
- Configuring release workflow and CI
For Nuxt module development: use nuxt-modules skill
Quick Reference
| Working on... | Load file |
|---|---|
| New project setup | references/project-setup.md |
| Package exports | references/package-exports.md |
| tsconfig options | references/typescript-config.md |
| Build configuration | references/build-tooling.md |
| ESLint config | references/eslint-config.md |
| API design patterns | references/api-design.md |
| Type inference tricks | references/type-patterns.md |
| Testing setup | references/testing.md |
| Release workflow | references/release.md |
| CI/CD setup | references/ci-workflows.md |
Loading Files
Consider loading these reference files based on your task:
- [ ] references/project-setup.md - if starting a new TypeScript library project
- [ ] references/package-exports.md - if configuring package.json exports or dual CJS/ESM
- [ ] references/typescript-config.md - if setting up or modifying tsconfig.json
- [ ] references/build-tooling.md - if configuring tsdown, unbuild, or build scripts
- [ ] references/eslint-config.md - if setting up ESLint for library development
- [ ] references/api-design.md - if designing public APIs, builder patterns, or plugin systems
- [ ] references/type-patterns.md - if working with advanced TypeScript types or type inference
- [ ] references/testing.md - if setting up vitest or writing tests for library code
- [ ] references/release.md - if configuring release workflow or versioning
- [ ] references/ci-workflows.md - if setting up GitHub Actions or CI/CD pipelines
DO NOT load all files at once. Load only what's relevant to your current task.
New Library Workflow
1. Create project structure → load references/project-setup.md 2. Configure package.json exports → load references/package-exports.md 3. Set up build with tsdown → load references/build-tooling.md 4. Verify build: pnpm build && pnpm pack --dry-run — check output includes .mjs, .cjs, .d.ts 5. Add tests → load references/testing.md 6. Configure release → load references/release.md
Quick Start
// package.json (minimal)
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": ["dist"]
}// tsdown.config.ts
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
})Key Principles
- ESM-first:
"type": "module"with.mjsoutputs - Dual format: always support both CJS and ESM consumers
moduleResolution: "Bundler"for modern TypeScript- tsdown for most builds, unbuild for complex cases
- Smart defaults: detect environment, don't force config
- Tree-shakeable: lazy getters, proper
sideEffects: false
_Token efficiency: Main skill ~300 tokens, each reference ~800-1200 tokens_
API Design Patterns
Options Pattern
User-facing options with internal resolved version:
export interface Options {
verbose?: boolean
include?: string[]
exclude?: string[]
}
export interface ResolvedOptions extends Required<Options> {
root: string
}
function resolveOptions(options: Options = {}): ResolvedOptions {
return {
verbose: options.verbose ?? false,
include: options.include ?? ['**/*'],
exclude: options.exclude ?? ['node_modules'],
root: process.cwd(),
}
}Factory Functions
Create configured instances:
export function createContext(options: Options = {}) {
const resolved = resolveOptions(options)
const filter = createFilter(resolved.include, resolved.exclude)
return {
options: resolved,
filter,
transform(code: string, id: string) { /* ... */ },
async scanDirs() { /* ... */ },
}
}
// Usage
const ctx = createContext({ verbose: true })
await ctx.scanDirs()Builder Pattern
Chainable API with type accumulation:
export function createBuilder<TContext = unknown>() {
return {
context<T>(): Builder<T, unknown, unknown> {
return this as any
},
input<T>(schema: T): Builder<TContext, T, unknown> {
return this as any
},
output<T>(schema: T): Builder<TContext, unknown, T> {
return this as any
},
build(): Procedure<TContext> { /* ... */ },
}
}
// Usage - types flow through chain
const procedure = createBuilder()
.context<{ user: User }>()
.input(z.object({ id: z.string() }))
.build()Plugin Pattern (unplugin)
Universal plugin from single implementation:
import { createUnplugin } from 'unplugin'
export default createUnplugin<Options>((options) => {
const ctx = createContext(options)
return {
name: 'my-plugin',
enforce: 'pre',
transformInclude(id) {
return ctx.filter(id)
},
transform(code, id) {
return ctx.transform(code, id)
},
// Bundler-specific hooks
vite: {
configResolved(config) { /* Vite-specific */ },
},
webpack(compiler) {
compiler.hooks.watchRun.tap('my-plugin', () => { /* ... */ })
},
}
})Export per-bundler entries:
// src/vite.ts
import unplugin from '.'
export default unplugin.vite
// src/webpack.ts
import unplugin from '.'
export default unplugin.webpackLazy Getters (Tree-shaking)
Defer bundler-specific code until accessed:
export function createPlugin<T>(factory: PluginFactory<T>) {
return {
get vite() { return getVitePlugin(factory) },
get webpack() { return getWebpackPlugin(factory) },
get rollup() { return getRollupPlugin(factory) },
}
}Only the accessed getter runs, rest is tree-shaken.
Smart Defaults
Detect environment instead of requiring config:
import { isPackageExists } from 'local-pkg'
function resolveOptions(options: Options) {
return {
vue: options.vue ?? isPackageExists('vue'),
react: options.react ?? isPackageExists('react'),
typescript: options.typescript ?? isPackageExists('typescript'),
}
}Resolver Pattern
Flexible resolution with function or object:
export type Resolver = ResolverFunction | ResolverObject
export type ResolverFunction = (name: string) => ResolveResult | undefined
export interface ResolverObject {
type: 'component' | 'directive'
resolve: ResolverFunction
}
export function ElementPlusResolver(): Resolver[] {
return [
{ type: 'component', resolve: (name) => resolveComponent(name) },
{ type: 'directive', resolve: (name) => resolveDirective(name) },
]
}Fluent API (Validation)
Method chaining with clone for immutability:
class Schema<T> {
private _def: SchemaDef
min(value: number): Schema<T> {
return new Schema({ ...this._def, min: value })
}
max(value: number): Schema<T> {
return new Schema({ ...this._def, max: value })
}
optional(): Schema<T | undefined> {
return new Schema({ ...this._def, optional: true })
}
}
// Usage
const schema = z.string().min(5).max(10).optional()Barrel Exports
Clean public API:
// src/index.ts
export * from './config'
export * from './types'
export { createContext } from './context'
export { default } from './plugin'Type Patterns
Utility Types
Common helpers used across libraries:
// Promise or sync
export type Awaitable<T> = T | Promise<T>
// Single or array
export type Arrayable<T> = T | T[]
// Nullable
export type Nullable<T> = T | null | undefined
// Deep partial
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// Simplify intersection for better IDE display
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
// Prevent inference in specific position
export type NoInfer<T> = [T][T extends any ? 0 : never]Conditional Extraction
Extract types from structures:
// Extract input type from schema
export type Input<T> = T extends { _input: infer U } ? U : unknown
// Extract output type
export type Output<T> = T extends { _output: infer U } ? U : unknown
// Extract from nested property
export type InferContext<T> = T extends { context: infer C } ? C : neverBrand Types
Nominal typing for primitives:
declare const brand: unique symbol
export type Brand<T, B> = T & { readonly [brand]: B }
export type UserId = Brand<string, 'UserId'>
export type PostId = Brand<string, 'PostId'>
// Can't mix them up
function getUser(id: UserId) { /* ... */ }
getUser('abc' as UserId) // OK
getUser('abc' as PostId) // Error!Type Accumulation (Builders)
Each method updates generic parameters:
interface ProcedureBuilder<TContext, TInput, TOutput> {
input<T>(schema: T): ProcedureBuilder<TContext, T, TOutput>
output<T>(schema: T): ProcedureBuilder<TContext, TInput, T>
query(fn: (opts: { ctx: TContext; input: TInput }) => TOutput): Procedure
}
// Types flow through the chain
const proc = builder
.input(z.object({ id: z.string() })) // TInput = { id: string }
.output(z.object({ name: z.string() })) // TOutput = { name: string }
.query(({ input }) => ({ name: input.id }))Module Augmentation
Allow users to extend library types:
// Library code
export interface Register {}
export type DefaultError = Register extends { defaultError: infer E }
? E
: Error
// User code
declare module 'my-lib' {
interface Register {
defaultError: MyCustomError
}
}Data Tagging
Attach type metadata with symbols:
declare const dataTagSymbol: unique symbol
declare const errorTagSymbol: unique symbol
export type DataTag<TType, TData, TError> = TType & {
[dataTagSymbol]: TData
[errorTagSymbol]: TError
}
// Extract tagged types
export type InferData<T> = T extends { [dataTagSymbol]: infer D } ? D : unknownMapped Type Modifications
Column builder pattern (drizzle):
type NotNull<T extends ColumnBuilder> = T & { _: { notNull: true } }
type HasDefault<T extends ColumnBuilder> = T & { _: { hasDefault: true } }
class ColumnBuilder<T extends ColumnConfig> {
notNull(): NotNull<this> {
// ...
return this as NotNull<this>
}
default(value: T['data']): HasDefault<this> {
// ...
return this as HasDefault<this>
}
}Compile-Time Errors
Return readable error messages:
type TypeError<Message extends string> = { __error: Message }
type ValidateInput<T> = T extends string
? T
: TypeError<'Input must be a string'>
// Shows: Type 'TypeError<"Input must be a string">' is not assignable...Function Overloads
Multiple signatures for different inputs:
export function useEventListener<E extends keyof WindowEventMap>(
event: E,
listener: (ev: WindowEventMap[E]) => any
): void
export function useEventListener<E extends keyof DocumentEventMap>(
target: Document,
event: E,
listener: (ev: DocumentEventMap[E]) => any
): void
export function useEventListener(...args: any[]) {
// Implementation
}Distributive Conditionals
Apply to each union member:
type ToArray<T> = T extends any ? T[] : never
type Result = ToArray<string | number>
// Result = string[] | number[]Disable distribution with tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type Result = ToArrayNonDist<string | number>
// Result = (string | number)[]API Design Patterns
Options Pattern
User-facing options with internal resolved version:
export interface Options {
verbose?: boolean
include?: string[]
exclude?: string[]
}
export interface ResolvedOptions extends Required<Options> {
root: string
}
function resolveOptions(options: Options = {}): ResolvedOptions {
return {
verbose: options.verbose ?? false,
include: options.include ?? ['**/*'],
exclude: options.exclude ?? ['node_modules'],
root: process.cwd(),
}
}Factory Functions
Create configured instances:
export function createContext(options: Options = {}) {
const resolved = resolveOptions(options)
const filter = createFilter(resolved.include, resolved.exclude)
return {
options: resolved,
filter,
transform(code: string, id: string) { /* ... */ },
async scanDirs() { /* ... */ },
}
}
// Usage
const ctx = createContext({ verbose: true })
await ctx.scanDirs()Builder Pattern
Chainable API with type accumulation:
export function createBuilder<TContext = unknown>() {
return {
context<T>(): Builder<T, unknown, unknown> {
return this as any
},
input<T>(schema: T): Builder<TContext, T, unknown> {
return this as any
},
output<T>(schema: T): Builder<TContext, unknown, T> {
return this as any
},
build(): Procedure<TContext> { /* ... */ },
}
}
// Usage - types flow through chain
const procedure = createBuilder()
.context<{ user: User }>()
.input(z.object({ id: z.string() }))
.build()Plugin Pattern (unplugin)
Universal plugin from single implementation:
import { createUnplugin } from 'unplugin'
export default createUnplugin<Options>((options) => {
const ctx = createContext(options)
return {
name: 'my-plugin',
enforce: 'pre',
transformInclude(id) {
return ctx.filter(id)
},
transform(code, id) {
return ctx.transform(code, id)
},
// Bundler-specific hooks
vite: {
configResolved(config) { /* Vite-specific */ },
},
webpack(compiler) {
compiler.hooks.watchRun.tap('my-plugin', () => { /* ... */ })
},
}
})Export per-bundler entries:
// src/vite.ts
import unplugin from '.'
export default unplugin.vite
// src/webpack.ts
import unplugin from '.'
export default unplugin.webpackLazy Getters (Tree-shaking)
Defer bundler-specific code until accessed:
export function createPlugin<T>(factory: PluginFactory<T>) {
return {
get vite() { return getVitePlugin(factory) },
get webpack() { return getWebpackPlugin(factory) },
get rollup() { return getRollupPlugin(factory) },
}
}Only the accessed getter runs, rest is tree-shaken.
Smart Defaults
Detect environment instead of requiring config:
import { isPackageExists } from 'local-pkg'
function resolveOptions(options: Options) {
return {
vue: options.vue ?? isPackageExists('vue'),
react: options.react ?? isPackageExists('react'),
typescript: options.typescript ?? isPackageExists('typescript'),
}
}Resolver Pattern
Flexible resolution with function or object:
export type Resolver = ResolverFunction | ResolverObject
export type ResolverFunction = (name: string) => ResolveResult | undefined
export interface ResolverObject {
type: 'component' | 'directive'
resolve: ResolverFunction
}
export function ElementPlusResolver(): Resolver[] {
return [
{ type: 'component', resolve: (name) => resolveComponent(name) },
{ type: 'directive', resolve: (name) => resolveDirective(name) },
]
}Fluent API (Validation)
Method chaining with clone for immutability:
class Schema<T> {
private _def: SchemaDef
min(value: number): Schema<T> {
return new Schema({ ...this._def, min: value })
}
max(value: number): Schema<T> {
return new Schema({ ...this._def, max: value })
}
optional(): Schema<T | undefined> {
return new Schema({ ...this._def, optional: true })
}
}
// Usage
const schema = z.string().min(5).max(10).optional()Barrel Exports
Clean public API:
// src/index.ts
export * from './config'
export * from './types'
export { createContext } from './context'
export { default } from './plugin'Build Tooling
Tool Selection
| Tool | Use case |
|---|---|
| tsdown | Most libraries - fast, simple, modern |
| unbuild | Complex builds, Nuxt modules, auto-externals |
| rollup/rolldown | Large projects needing fine control |
tsdown (Recommended)
pnpm add -D tsdownBasic Config
// tsdown.config.ts
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
})Multiple Entries
export default defineConfig({
entry: ['src/index.ts', 'src/cli.ts', 'src/utils.ts'],
format: ['esm', 'cjs'],
dts: true,
external: ['vue', 'vite'],
})Plugin Pattern (unplugin-\*)
export default defineConfig({
entry: ['src/*.ts'], // Glob all entries
format: ['esm', 'cjs'],
dts: true,
exports: true, // Auto-generate package.json exports
attw: { profile: 'esm-only' }, // Type checking profile
})Advanced Options
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: {
resolve: ['@antfu/utils'], // Inline specific deps in declarations
},
external: ['vue'],
define: {
__DEV__: 'false',
},
hooks: {
'build:done': async () => {
// Post-build tasks
},
},
})unbuild
pnpm add -D unbuildBasic Config
// build.config.ts
import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
entries: ['src/index'],
declaration: true,
rollup: {
emitCJS: true,
},
})With Externals
export default defineBuildConfig({
entries: ['src/index', 'src/cli'],
declaration: true,
externals: ['vue', 'vite'],
rollup: {
emitCJS: true,
inlineDependencies: true,
dts: { respectExternal: true },
},
})Output Formats
ESM Only (modern)
export default defineConfig({
format: ['esm'],
})Dual CJS/ESM (recommended)
export default defineConfig({
format: ['esm', 'cjs'],
})With IIFE for CDN
export default defineConfig([
{ format: ['esm', 'cjs'], dts: true },
{ format: 'iife', globalName: 'MyLib', minify: true },
])Define Flags
Common compile-time flags:
export default defineConfig({
define: {
__DEV__: `(process.env.NODE_ENV !== 'production')`,
__TEST__: 'false',
__BROWSER__: 'true',
__VERSION__: JSON.stringify(pkg.version),
},
})Build Scripts
{
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"prepublishOnly": "pnpm build"
}
}Troubleshooting
CJS default export issues
Some bundlers need explicit default:
export default defineConfig({
hooks: {
'build:done': async () => {
// Patch CJS files if needed
},
},
})Missing types in output
Ensure dts: true and check isolatedDeclarations in tsconfig.
External not working
Check package is in peerDependencies and listed in external.
CI Workflows
Basic CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm testMatrix Testing
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest]
node: [20, 22, 24]
include:
- os: macos-latest
node: 24
- os: windows-latest
node: 24
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install
- run: pnpm testSkip Docs-Only Changes
jobs:
changed:
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.check.outputs.only_changed == 'true' }}
steps:
- uses: tj-actions/changed-files@v47
id: check
with:
files: |
docs/**
**.md
test:
needs: changed
if: needs.changed.outputs.should_skip != 'true'
# ... rest of jobAuto-fix Commits
- run: pnpm lint:fix
- uses: stefanzweifel/git-auto-commit-action@v5
if: github.event_name == 'push'
with:
commit_message: 'chore: lint fix'Release on Tag (Token-based)
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install
- run: pnpm build
- run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}Release on Tag (OIDC - Recommended)
No NPM_TOKEN needed. Uses GitHub OIDC for tokenless auth with provenance.
name: Release
permissions:
id-token: write
contents: write
actions: read
on:
push:
tags: ['v*']
jobs:
wait-for-ci:
runs-on: ubuntu-latest
steps:
- uses: lewagon/wait-on-check-action@v1.3.4
with:
ref: ${{ github.sha }}
check-name: ci
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
release:
needs: wait-for-ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24 # Required: npm 11.5.1+
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install
- run: pnpm build
- run: pnpm dlx changelogithub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm publish --access public --no-git-checks --provenanceOIDC Setup Steps
1. Open https://www.npmjs.com/package/<PACKAGE_NAME>/access 2. Scroll to "Publishing access" section 3. Click "Add GitHub Actions" under Trusted Publishers 4. Fill: Owner, Repository, Workflow file (release.yml), Environment (empty) 5. Click "Add"
OIDC Requirements
1. Node.js 24+ (npm 11.5.1+ required - Node 22 has npm 10.x which fails) 2. Permissions: id-token: write 3. Publish flag: --provenance 4. package.json: must have repository field 5. npm 2FA: "Require 2FA or granular access token" (allows OIDC)
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| "Access token expired" E404 | npm too old | Use Node.js 24 |
| ENEEDAUTH | Missing registry-url | Add registry-url to setup-node |
| "repository.url is empty" E422 | Missing field | Add repository to package.json |
| "not configured as trusted publisher" | Config mismatch | Check owner, repo, workflow match exactly |
Monorepo Matrix
jobs:
test:
strategy:
matrix:
package: [core, utils, cli]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm --filter ${{ matrix.package }} testConcurrency Control
Cancel outdated runs:
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: truepkg-pr-new for PRs
# .github/workflows/pkg-pr-new.yml
name: Publish PR
on: pull_request
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm build
- run: pnpm dlx pkg-pr-new publish --compact --pnpmPackage Validation in CI
- run: pnpm build
- run: pnpm dlx publint
- run: pnpm dlx @arethetypeswrong/cli --pack .@antfu/eslint-config
Flat ESLint config that handles both linting and formatting - replaces Prettier.
Setup
pnpm add -D eslint @antfu/eslint-config// eslint.config.mjs
import antfu from '@antfu/eslint-config'
export default antfu(){ "scripts": { "lint": "eslint ." } }Configuration Options
import antfu from '@antfu/eslint-config'
export default antfu({
type: 'lib', // 'lib' for libraries, 'app' for applications
ignores: ['**/fixtures', '**/dist'],
stylistic: { indent: 2, quotes: 'single' },
typescript: true, // Auto-detected
vue: true, // Auto-detected
})Framework Support
| Framework | Option | Required Package |
|---|---|---|
| Vue | vue: true | (auto-detected) |
| React | react: true | @eslint-react/eslint-plugin eslint-plugin-react-hooks |
| Next.js | nextjs: true | @next/eslint-plugin-next |
| Svelte | svelte: true | eslint-plugin-svelte |
| Astro | astro: true | eslint-plugin-astro |
| Solid | solid: true | eslint-plugin-solid |
| UnoCSS | unocss: true | @unocss/eslint-plugin |
Formatters (CSS, HTML, Markdown)
For files ESLint doesn't handle natively:
export default antfu({
formatters: {
css: true, // Prettier for CSS/LESS/SCSS
html: true, // Prettier for HTML
markdown: 'prettier' // or 'dprint'
}
})
// Requires: pnpm add -D eslint-plugin-formatRule Overrides
Global
export default antfu(
{ /* config options */ },
{ rules: { 'style/semi': ['error', 'never'] } }
)Per-integration
export default antfu({
vue: { overrides: { 'vue/operator-linebreak': ['error', 'before'] } },
typescript: { overrides: { 'ts/consistent-type-definitions': ['error', 'interface'] } },
})Plugin Prefix Renaming
| New Prefix | Original |
|---|---|
ts/* | @typescript-eslint/* |
style/* | @stylistic/* |
import/* | import-lite/* |
node/* | n/* |
test/* | vitest/* |
// eslint-disable-next-line ts/consistent-type-definitionsType-Aware Rules
export default antfu({
typescript: { tsconfigPath: 'tsconfig.json' },
})VS Code Settings
{
"prettier.enable": false,
"editor.formatOnSave": false,
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit", "source.organizeImports": "never" },
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true }
],
"eslint.validate": ["javascript", "typescript", "vue", "html", "markdown", "json", "yaml"]
}Package Exports
Basic Single Entry
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"sideEffects": false,
"files": ["dist"]
}Multiple Entry Points
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.mts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./*": "./dist/*"
}
}Plugin Entry Pattern (unplugin-\*)
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./vite": {
"types": "./dist/vite.d.mts",
"import": "./dist/vite.mjs",
"require": "./dist/vite.cjs"
},
"./webpack": {
"types": "./dist/webpack.d.mts",
"import": "./dist/webpack.mjs",
"require": "./dist/webpack.cjs"
},
"./nuxt": {
"types": "./dist/nuxt.d.mts",
"import": "./dist/nuxt.mjs",
"require": "./dist/nuxt.cjs"
}
}
}Environment-Aware Exports
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": { "production": "./dist/index.prod.mjs", "development": "./dist/index.mjs" },
"require": { "production": "./dist/index.prod.cjs", "development": "./dist/index.cjs" }
},
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}typesVersions Fallback
For older TypeScript versions without exports support:
{
"typesVersions": {
"*": {
"*": ["./dist/*", "./*"]
}
}
}Field Reference
| Field | Purpose |
|---|---|
exports | Modern entry points (Node 12.7+) |
main | CJS fallback for older bundlers |
module | ESM fallback for bundlers |
types | TypeScript fallback |
sideEffects | false enables tree-shaking |
files | What gets published to npm |
Condition Order
Order matters! Put most specific first:
{
".": {
"types": "...", // Always first
"import": "...", // ESM
"require": "..." // CJS fallback
}
}Peer Dependencies
External deps that consumers must provide:
{
"peerDependencies": {
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"vue": { "optional": true }
}
}Package Validation
# Check exports are correct
pnpm dlx publint
pnpm dlx @arethetypeswrong/cliAdd to CI for continuous validation.
Project Setup
Single Package
# Clone starter template
cp -r ~/templates/antfu/starter-ts my-lib
cd my-lib && rm -rf .git && git init
pnpm installOr manual setup:
mkdir my-lib && cd my-lib
pnpm init
pnpm add -D typescript tsdown vitest eslint @antfu/eslint-configDirectory Structure
my-lib/
├── src/
│ ├── index.ts # Main entry
│ └── types.ts # Type definitions
├── test/
│ └── index.test.ts
├── dist/ # Build output (gitignored)
├── package.json
├── tsconfig.json
├── tsdown.config.ts
├── eslint.config.ts
└── vitest.config.tsMonorepo
cp -r ~/templates/antfu/starter-monorepo my-monorepo
cd my-monorepo && rm -rf .git && git init
pnpm installStructure
my-monorepo/
├── packages/
│ ├── core/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsdown.config.ts
│ └── cli/
│ ├── src/
│ └── package.json
├── playground/ # Integration tests
├── pnpm-workspace.yaml
├── package.json # Root scripts, devDeps
├── tsconfig.json # Base config
└── eslint.config.tspnpm-workspace.yaml
packages:
- packages/*
- playground
catalogs:
build:
tsdown: ^0.15.0
unbuild: ^3.0.0
lint:
eslint: ^9.0.0
'@antfu/eslint-config': ^4.0.0
test:
vitest: ^3.0.0
types:
typescript: ^5.7.0pnpm Catalogs
Organize dependencies by purpose (from antfu's blog post):
| Category | Contents |
|---|---|
| build | tsdown, unbuild, rollup plugins |
| lint | eslint, @antfu/eslint-config |
| test | vitest, @vue/test-utils |
| types | typescript, @types/\* |
| prod | Runtime deps: consola, defu, pathe |
Using Catalogs
{
"devDependencies": {
"tsdown": "catalog:build",
"eslint": "catalog:lint",
"vitest": "catalog:test",
"typescript": "catalog:types"
}
}ESLint Setup
pnpm add -D eslint @antfu/eslint-config// eslint.config.ts
import antfu from '@antfu/eslint-config'
export default antfu({
type: 'lib',
pnpm: true,
formatters: true,
})Git Hooks
pnpm add -D simple-git-hooks lint-staged{
"simple-git-hooks": { "pre-commit": "pnpm lint-staged" },
"lint-staged": { "*": "eslint --fix" },
"scripts": { "prepare": "simple-git-hooks" }
}Run pnpm prepare after adding.
Scripts
{
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest",
"release": "bumpp",
"prepublishOnly": "pnpm build"
}
}Release Workflow
Tools
| Tool | Purpose |
|---|---|
| bumpp | Interactive version bumping |
| changelogen | Changelog generation from commits |
| pkg-pr-new | PR preview packages |
bumpp (Version Bumping)
pnpm add -D bumpp{
"scripts": {
"release": "bumpp"
}
}Interactive prompt for patch/minor/major. Options:
{
"scripts": {
"release": "bumpp --commit --tag --push"
}
}For monorepos:
bumpp -r # Recursive
bumpp packages/*/package.json # Specific packageschangelogen (Changelog)
pnpm add -D changelogen{
"scripts": {
"changelog": "changelogen --release"
}
}Combined workflow:
{
"scripts": {
"release": "changelogen --release && bumpp"
}
}Full Release Flow
{
"scripts": {
"release": "pnpm lint && pnpm test && changelogen --release && bumpp --commit --tag --push"
}
}CI publishes to npm on tag push.
pkg-pr-new (PR Previews)
For publishable packages. Creates install links on PRs.
# .github/workflows/pkg-pr-new.yml
name: Publish PR
on: pull_request
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm build
- run: pnpm dlx pkg-pr-new publish --compact --pnpmFor monorepos:
pnpm dlx pkg-pr-new publish --compact --pnpm './packages/*'PR comment shows:
pnpm add https://pkg.pr.new/your-org/your-package@123Conventional Commits
For changelogen to work:
feat: add dark mode support
fix: resolve memory leak in parser
docs: update README
chore: update dependenciesnpm Publishing
Token-based (legacy)
- run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}OIDC (Recommended)
No token needed. See ci-workflows.md for full setup.
- run: pnpm publish --access public --no-git-checks --provenanceMonorepo Publishing
With pnpm:
pnpm -r publish --access publicWith bumpp:
bumpp -r && pnpm -r publishPre-release Versions
bumpp --preid beta # 1.0.0 -> 1.0.1-beta.0
bumpp --preid alpha # 1.0.0 -> 1.0.1-alpha.0Package.json Requirements
{
"name": "@scope/package",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/org/repo.git"
},
"publishConfig": {
"access": "public"
}
}repository required for npm provenance.
Testing
Vitest Setup
pnpm add -D vitestBasic Config
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
testTimeout: 30_000,
reporters: 'dot',
},
})With Coverage
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/types.ts'],
reporter: ['text', 'lcovonly', 'html'],
},
},
})Workspace Projects
For monorepos, test packages separately:
export default defineConfig({
test: {
projects: [
'packages/*/vitest.config.ts',
{
extends: './vitest.config.ts',
test: { name: 'unit', environment: 'node' },
},
{
extends: './vitest.config.ts',
test: { name: 'browser', browser: { enabled: true } },
},
],
},
})Fixture-Based Testing
Test transforms with file fixtures:
import { describe, expect, it } from 'vitest'
import { transform } from '../src'
const fixtures = import.meta.glob('./fixtures/*.ts', { as: 'raw' })
describe('transform', () => {
for (const [path, getContent] of Object.entries(fixtures)) {
it(path, async () => {
const content = await getContent()
const result = await transform(content)
expect(result).toMatchSnapshot()
})
}
})Idempotency Testing
Ensure transforms are stable:
it('transform is idempotent', async () => {
const pass1 = (await transform(fixture))?.code ?? fixture
expect(pass1).toMatchSnapshot()
const pass2 = (await transform(pass1))?.code ?? pass1
expect(pass2).toBe(pass1) // Should not change
})Type-Level Testing
Test TypeScript types:
// vitest.config.ts
export default defineConfig({
test: {
typecheck: { enabled: true },
},
})// test/types.test-d.ts
import { describe, expectTypeOf, it } from 'vitest'
import type { Input, Output } from '../src'
describe('types', () => {
it('infers input correctly', () => {
expectTypeOf<Input<typeof schema>>().toEqualTypeOf<{ id: string }>()
})
})Multi-TS Version Testing
Test across TypeScript versions (TanStack pattern):
# .github/workflows/ci.yml
jobs:
test-types:
strategy:
matrix:
ts: ['5.0', '5.2', '5.4', '5.6', '5.8']
steps:
- run: pnpm add -D typescript@${{ matrix.ts }}
- run: pnpm typecheckPackage Validation
Validate published package:
# Check exports are correct
pnpm dlx publint
# Check types work in different moduleResolutions
pnpm dlx @arethetypeswrong/cli --pack .Add to tsdown config:
export default defineConfig({
attw: { profile: 'esm-only' }, // or 'node16'
})Test Scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:types": "vitest typecheck"
}
}Mocking
import { vi } from 'vitest'
vi.mock('fs', () => ({
readFileSync: vi.fn(() => 'mocked content'),
}))
// Spy on method
const spy = vi.spyOn(console, 'log')
expect(spy).toHaveBeenCalledWith('expected')Testing Plugins
Dogfood your own plugin in tests:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import MyPlugin from './src/vite'
export default defineConfig({
plugins: [
MyPlugin({ /* options */ }),
],
test: {
include: ['test/**/*.test.ts'],
},
})Type Patterns
Utility Types
Common helpers used across libraries:
// Promise or sync
export type Awaitable<T> = T | Promise<T>
// Single or array
export type Arrayable<T> = T | T[]
// Nullable
export type Nullable<T> = T | null | undefined
// Deep partial
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// Simplify intersection for better IDE display
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
// Prevent inference in specific position
export type NoInfer<T> = [T][T extends any ? 0 : never]Conditional Extraction
Extract types from structures:
// Extract input type from schema
export type Input<T> = T extends { _input: infer U } ? U : unknown
// Extract output type
export type Output<T> = T extends { _output: infer U } ? U : unknown
// Extract from nested property
export type InferContext<T> = T extends { context: infer C } ? C : neverBrand Types
Nominal typing for primitives:
declare const brand: unique symbol
export type Brand<T, B> = T & { readonly [brand]: B }
export type UserId = Brand<string, 'UserId'>
export type PostId = Brand<string, 'PostId'>
// Can't mix them up
function getUser(id: UserId) { /* ... */ }
getUser('abc' as UserId) // OK
getUser('abc' as PostId) // Error!Type Accumulation (Builders)
Each method updates generic parameters:
interface ProcedureBuilder<TContext, TInput, TOutput> {
input<T>(schema: T): ProcedureBuilder<TContext, T, TOutput>
output<T>(schema: T): ProcedureBuilder<TContext, TInput, T>
query(fn: (opts: { ctx: TContext; input: TInput }) => TOutput): Procedure
}
// Types flow through the chain
const proc = builder
.input(z.object({ id: z.string() })) // TInput = { id: string }
.output(z.object({ name: z.string() })) // TOutput = { name: string }
.query(({ input }) => ({ name: input.id }))Module Augmentation
Allow users to extend library types:
// Library code
export interface Register {}
export type DefaultError = Register extends { defaultError: infer E }
? E
: Error
// User code
declare module 'my-lib' {
interface Register {
defaultError: MyCustomError
}
}Data Tagging
Attach type metadata with symbols:
declare const dataTagSymbol: unique symbol
declare const errorTagSymbol: unique symbol
export type DataTag<TType, TData, TError> = TType & {
[dataTagSymbol]: TData
[errorTagSymbol]: TError
}
// Extract tagged types
export type InferData<T> = T extends { [dataTagSymbol]: infer D } ? D : unknownMapped Type Modifications
Column builder pattern (drizzle):
type NotNull<T extends ColumnBuilder> = T & { _: { notNull: true } }
type HasDefault<T extends ColumnBuilder> = T & { _: { hasDefault: true } }
class ColumnBuilder<T extends ColumnConfig> {
notNull(): NotNull<this> {
// ...
return this as NotNull<this>
}
default(value: T['data']): HasDefault<this> {
// ...
return this as HasDefault<this>
}
}Compile-Time Errors
Return readable error messages:
type TypeError<Message extends string> = { __error: Message }
type ValidateInput<T> = T extends string
? T
: TypeError<'Input must be a string'>
// Shows: Type 'TypeError<"Input must be a string">' is not assignable...Function Overloads
Multiple signatures for different inputs:
export function useEventListener<E extends keyof WindowEventMap>(
event: E,
listener: (ev: WindowEventMap[E]) => any
): void
export function useEventListener<E extends keyof DocumentEventMap>(
target: Document,
event: E,
listener: (ev: DocumentEventMap[E]) => any
): void
export function useEventListener(...args: any[]) {
// Implementation
}Distributive Conditionals
Apply to each union member:
type ToArray<T> = T extends any ? T[] : never
type Result = ToArray<string | number>
// Result = string[] | number[]Disable distribution with tuple:
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never
type Result = ToArrayNonDist<string | number>
// Result = (string | number)[]TypeScript Configuration
Library Base Config
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext"],
"strict": true,
"strictNullChecks": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Key Options Explained
| Option | Value | Why |
|---|---|---|
target | ESNext | Modern output, bundlers downgrade |
module | ESNext | ESM output |
moduleResolution | Bundler | Works with modern bundlers, allows no extensions |
strict | true | Catch errors early |
noEmit | true | Build tool handles emit |
isolatedDeclarations | true | Faster DTS generation |
verbatimModuleSyntax | true | Explicit import type required |
skipLibCheck | true | Faster builds |
Monorepo Config
Root tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"verbatimModuleSyntax": true
}
}Package tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"references": [
{ "path": "../utils" }
]
}Path Aliases
For internal imports in monorepos:
{
"compilerOptions": {
"paths": {
"@my-lib/core": ["./packages/core/src"],
"@my-lib/utils": ["./packages/utils/src"],
"#internal/*": ["./virtual-shared/*"]
}
}
}Bundler vs Node Resolution
Use `Bundler` for libraries consumed by bundlers (Vite, webpack, etc.):
- Allows importing without extensions
- Supports
exportsfield in package.json - Modern, simpler setup
Use `Node16/NodeNext` for Node.js-only libraries:
- Requires explicit extensions (
.js) - Stricter, matches Node.js behavior exactly
Type Declarations
Let build tool generate declarations:
// tsdown.config.ts
export default defineConfig({
dts: true, // Generate .d.ts
dts: { resolve: ['@antfu/utils'] } // Inline specific types
})Or with unbuild:
// build.config.ts
export default defineBuildConfig({
declaration: 'node16', // For Node.js compatibility
declaration: true, // For bundler resolution
})Common Issues
Module not found errors
Check moduleResolution matches your target:
- Bundler:
"Bundler" - Node.js:
"Node16"or"NodeNext"
Type imports not working
Enable verbatimModuleSyntax and use explicit:
import type { Foo } from './types'Slow type checking
Enable skipLibCheck: true and isolatedDeclarations: true.
Package Exports
Basic Single Entry
{
"name": "my-lib",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"sideEffects": false,
"files": ["dist"]
}Multiple Entry Points
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./utils": {
"types": "./dist/utils.d.mts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
},
"./*": "./dist/*"
}
}Plugin Entry Pattern (unplugin-\*)
{
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
},
"./vite": {
"types": "./dist/vite.d.mts",
"import": "./dist/vite.mjs",
"require": "./dist/vite.cjs"
},
"./webpack": {
"types": "./dist/webpack.d.mts",
"import": "./dist/webpack.mjs",
"require": "./dist/webpack.cjs"
},
"./nuxt": {
"types": "./dist/nuxt.d.mts",
"import": "./dist/nuxt.mjs",
"require": "./dist/nuxt.cjs"
}
}
}Environment-Aware Exports
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"node": {
"import": { "production": "./dist/index.prod.mjs", "development": "./dist/index.mjs" },
"require": { "production": "./dist/index.prod.cjs", "development": "./dist/index.cjs" }
},
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}typesVersions Fallback
For older TypeScript versions without exports support:
{
"typesVersions": {
"*": {
"*": ["./dist/*", "./*"]
}
}
}Field Reference
| Field | Purpose |
|---|---|
exports | Modern entry points (Node 12.7+) |
main | CJS fallback for older bundlers |
module | ESM fallback for bundlers |
types | TypeScript fallback |
sideEffects | false enables tree-shaking |
files | What gets published to npm |
Condition Order
Order matters! Put most specific first:
{
".": {
"types": "...", // Always first
"import": "...", // ESM
"require": "..." // CJS fallback
}
}Peer Dependencies
External deps that consumers must provide:
{
"peerDependencies": {
"vue": "^3.0.0"
},
"peerDependenciesMeta": {
"vue": { "optional": true }
}
}Package Validation
# Check exports are correct
pnpm dlx publint
pnpm dlx @arethetypeswrong/cliAdd to CI for continuous validation.
Project Setup
Single Package
# Clone starter template
cp -r ~/templates/antfu/starter-ts my-lib
cd my-lib && rm -rf .git && git init
pnpm installOr manual setup:
mkdir my-lib && cd my-lib
pnpm init
pnpm add -D typescript tsdown vitest eslint @antfu/eslint-configDirectory Structure
my-lib/
├── src/
│ ├── index.ts # Main entry
│ └── types.ts # Type definitions
├── test/
│ └── index.test.ts
├── dist/ # Build output (gitignored)
├── package.json
├── tsconfig.json
├── tsdown.config.ts
├── eslint.config.ts
└── vitest.config.tsMonorepo
cp -r ~/templates/antfu/starter-monorepo my-monorepo
cd my-monorepo && rm -rf .git && git init
pnpm installStructure
my-monorepo/
├── packages/
│ ├── core/
│ │ ├── src/
│ │ ├── package.json
│ │ └── tsdown.config.ts
│ └── cli/
│ ├── src/
│ └── package.json
├── playground/ # Integration tests
├── pnpm-workspace.yaml
├── package.json # Root scripts, devDeps
├── tsconfig.json # Base config
└── eslint.config.tspnpm-workspace.yaml
packages:
- packages/*
- playground
catalogs:
build:
tsdown: ^0.15.0
unbuild: ^3.0.0
lint:
eslint: ^9.0.0
'@antfu/eslint-config': ^4.0.0
test:
vitest: ^3.0.0
types:
typescript: ^5.7.0pnpm Catalogs
Organize dependencies by purpose (from antfu's blog post):
| Category | Contents |
|---|---|
| build | tsdown, unbuild, rollup plugins |
| lint | eslint, @antfu/eslint-config |
| test | vitest, @vue/test-utils |
| types | typescript, @types/\* |
| prod | Runtime deps: consola, defu, pathe |
Using Catalogs
{
"devDependencies": {
"tsdown": "catalog:build",
"eslint": "catalog:lint",
"vitest": "catalog:test",
"typescript": "catalog:types"
}
}ESLint Setup
pnpm add -D eslint @antfu/eslint-config// eslint.config.ts
import antfu from '@antfu/eslint-config'
export default antfu({
type: 'lib',
pnpm: true,
formatters: true,
})Git Hooks
pnpm add -D simple-git-hooks lint-staged{
"simple-git-hooks": { "pre-commit": "pnpm lint-staged" },
"lint-staged": { "*": "eslint --fix" },
"scripts": { "prepare": "simple-git-hooks" }
}Run pnpm prepare after adding.
Scripts
{
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"test": "vitest",
"release": "bumpp",
"prepublishOnly": "pnpm build"
}
}TypeScript Configuration
Library Base Config
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ESNext"],
"strict": true,
"strictNullChecks": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedDeclarations": true,
"verbatimModuleSyntax": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Key Options Explained
| Option | Value | Why |
|---|---|---|
target | ESNext | Modern output, bundlers downgrade |
module | ESNext | ESM output |
moduleResolution | Bundler | Works with modern bundlers, allows no extensions |
strict | true | Catch errors early |
noEmit | true | Build tool handles emit |
isolatedDeclarations | true | Faster DTS generation |
verbatimModuleSyntax | true | Explicit import type required |
skipLibCheck | true | Faster builds |
Monorepo Config
Root tsconfig.json
{
"compilerOptions": {
"composite": true,
"declaration": true,
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"verbatimModuleSyntax": true
}
}Package tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"references": [
{ "path": "../utils" }
]
}Path Aliases
For internal imports in monorepos:
{
"compilerOptions": {
"paths": {
"@my-lib/core": ["./packages/core/src"],
"@my-lib/utils": ["./packages/utils/src"],
"#internal/*": ["./virtual-shared/*"]
}
}
}Bundler vs Node Resolution
Use `Bundler` for libraries consumed by bundlers (Vite, webpack, etc.):
- Allows importing without extensions
- Supports
exportsfield in package.json - Modern, simpler setup
Use `Node16/NodeNext` for Node.js-only libraries:
- Requires explicit extensions (
.js) - Stricter, matches Node.js behavior exactly
Type Declarations
Let build tool generate declarations:
// tsdown.config.ts
export default defineConfig({
dts: true, // Generate .d.ts
dts: { resolve: ['@antfu/utils'] } // Inline specific types
})Or with unbuild:
// build.config.ts
export default defineBuildConfig({
declaration: 'node16', // For Node.js compatibility
declaration: true, // For bundler resolution
})Common Issues
Module not found errors
Check moduleResolution matches your target:
- Bundler:
"Bundler" - Node.js:
"Node16"or"NodeNext"
Type imports not working
Enable verbatimModuleSyntax and use explicit:
import type { Foo } from './types'Slow type checking
Enable skipLibCheck: true and isolatedDeclarations: true.
CI Workflows
Basic CI
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm lint
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm typecheck
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm testMatrix Testing
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest]
node: [20, 22, 24]
include:
- os: macos-latest
node: 24
- os: windows-latest
node: 24
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install
- run: pnpm testSkip Docs-Only Changes
jobs:
changed:
runs-on: ubuntu-latest
outputs:
should_skip: ${{ steps.check.outputs.only_changed == 'true' }}
steps:
- uses: tj-actions/changed-files@v47
id: check
with:
files: |
docs/**
**.md
test:
needs: changed
if: needs.changed.outputs.should_skip != 'true'
# ... rest of jobAuto-fix Commits
- run: pnpm lint:fix
- uses: stefanzweifel/git-auto-commit-action@v5
if: github.event_name == 'push'
with:
commit_message: 'chore: lint fix'Release on Tag (Token-based)
# .github/workflows/release.yml
name: Release
on:
push:
tags: ['v*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install
- run: pnpm build
- run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}Release on Tag (OIDC - Recommended)
No NPM_TOKEN needed. Uses GitHub OIDC for tokenless auth with provenance.
name: Release
permissions:
id-token: write
contents: write
actions: read
on:
push:
tags: ['v*']
jobs:
wait-for-ci:
runs-on: ubuntu-latest
steps:
- uses: lewagon/wait-on-check-action@v1.3.4
with:
ref: ${{ github.sha }}
check-name: ci
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
release:
needs: wait-for-ci
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24 # Required: npm 11.5.1+
cache: pnpm
registry-url: https://registry.npmjs.org
- run: pnpm install
- run: pnpm build
- run: pnpm dlx changelogithub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: pnpm publish --access public --no-git-checks --provenanceOIDC Setup Steps
1. Open https://www.npmjs.com/package/<PACKAGE_NAME>/access 2. Scroll to "Publishing access" section 3. Click "Add GitHub Actions" under Trusted Publishers 4. Fill: Owner, Repository, Workflow file (release.yml), Environment (empty) 5. Click "Add"
OIDC Requirements
1. Node.js 24+ (npm 11.5.1+ required - Node 22 has npm 10.x which fails) 2. Permissions: id-token: write 3. Publish flag: --provenance 4. package.json: must have repository field 5. npm 2FA: "Require 2FA or granular access token" (allows OIDC)
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| "Access token expired" E404 | npm too old | Use Node.js 24 |
| ENEEDAUTH | Missing registry-url | Add registry-url to setup-node |
| "repository.url is empty" E422 | Missing field | Add repository to package.json |
| "not configured as trusted publisher" | Config mismatch | Check owner, repo, workflow match exactly |
Monorepo Matrix
jobs:
test:
strategy:
matrix:
package: [core, utils, cli]
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm --filter ${{ matrix.package }} testConcurrency Control
Cancel outdated runs:
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: truepkg-pr-new for PRs
# .github/workflows/pkg-pr-new.yml
name: Publish PR
on: pull_request
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm build
- run: pnpm dlx pkg-pr-new publish --compact --pnpmPackage Validation in CI
- run: pnpm build
- run: pnpm dlx publint
- run: pnpm dlx @arethetypeswrong/cli --pack .Release Workflow
Tools
| Tool | Purpose |
|---|---|
| bumpp | Interactive version bumping |
| changelogen | Changelog generation from commits |
| pkg-pr-new | PR preview packages |
bumpp (Version Bumping)
pnpm add -D bumpp{
"scripts": {
"release": "bumpp"
}
}Interactive prompt for patch/minor/major. Options:
{
"scripts": {
"release": "bumpp --commit --tag --push"
}
}For monorepos:
bumpp -r # Recursive
bumpp packages/*/package.json # Specific packageschangelogen (Changelog)
pnpm add -D changelogen{
"scripts": {
"changelog": "changelogen --release"
}
}Combined workflow:
{
"scripts": {
"release": "changelogen --release && bumpp"
}
}Full Release Flow
{
"scripts": {
"release": "pnpm lint && pnpm test && changelogen --release && bumpp --commit --tag --push"
}
}CI publishes to npm on tag push.
pkg-pr-new (PR Previews)
For publishable packages. Creates install links on PRs.
# .github/workflows/pkg-pr-new.yml
name: Publish PR
on: pull_request
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- run: pnpm build
- run: pnpm dlx pkg-pr-new publish --compact --pnpmFor monorepos:
pnpm dlx pkg-pr-new publish --compact --pnpm './packages/*'PR comment shows:
pnpm add https://pkg.pr.new/your-org/your-package@123Conventional Commits
For changelogen to work:
feat: add dark mode support
fix: resolve memory leak in parser
docs: update README
chore: update dependenciesnpm Publishing
Token-based (legacy)
- run: pnpm publish --access public --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}OIDC (Recommended)
No token needed. See ci-workflows.md for full setup.
- run: pnpm publish --access public --no-git-checks --provenanceMonorepo Publishing
With pnpm:
pnpm -r publish --access publicWith bumpp:
bumpp -r && pnpm -r publishPre-release Versions
bumpp --preid beta # 1.0.0 -> 1.0.1-beta.0
bumpp --preid alpha # 1.0.0 -> 1.0.1-alpha.0Package.json Requirements
{
"name": "@scope/package",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "git+https://github.com/org/repo.git"
},
"publishConfig": {
"access": "public"
}
}repository required for npm provenance.
Testing
Vitest Setup
pnpm add -D vitestBasic Config
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
testTimeout: 30_000,
reporters: 'dot',
},
})With Coverage
export default defineConfig({
test: {
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/types.ts'],
reporter: ['text', 'lcovonly', 'html'],
},
},
})Workspace Projects
For monorepos, test packages separately:
export default defineConfig({
test: {
projects: [
'packages/*/vitest.config.ts',
{
extends: './vitest.config.ts',
test: { name: 'unit', environment: 'node' },
},
{
extends: './vitest.config.ts',
test: { name: 'browser', browser: { enabled: true } },
},
],
},
})Fixture-Based Testing
Test transforms with file fixtures:
import { describe, expect, it } from 'vitest'
import { transform } from '../src'
const fixtures = import.meta.glob('./fixtures/*.ts', { as: 'raw' })
describe('transform', () => {
for (const [path, getContent] of Object.entries(fixtures)) {
it(path, async () => {
const content = await getContent()
const result = await transform(content)
expect(result).toMatchSnapshot()
})
}
})Idempotency Testing
Ensure transforms are stable:
it('transform is idempotent', async () => {
const pass1 = (await transform(fixture))?.code ?? fixture
expect(pass1).toMatchSnapshot()
const pass2 = (await transform(pass1))?.code ?? pass1
expect(pass2).toBe(pass1) // Should not change
})Type-Level Testing
Test TypeScript types:
// vitest.config.ts
export default defineConfig({
test: {
typecheck: { enabled: true },
},
})// test/types.test-d.ts
import { describe, expectTypeOf, it } from 'vitest'
import type { Input, Output } from '../src'
describe('types', () => {
it('infers input correctly', () => {
expectTypeOf<Input<typeof schema>>().toEqualTypeOf<{ id: string }>()
})
})Multi-TS Version Testing
Test across TypeScript versions (TanStack pattern):
# .github/workflows/ci.yml
jobs:
test-types:
strategy:
matrix:
ts: ['5.0', '5.2', '5.4', '5.6', '5.8']
steps:
- run: pnpm add -D typescript@${{ matrix.ts }}
- run: pnpm typecheckPackage Validation
Validate published package:
# Check exports are correct
pnpm dlx publint
# Check types work in different moduleResolutions
pnpm dlx @arethetypeswrong/cli --pack .Add to tsdown config:
export default defineConfig({
attw: { profile: 'esm-only' }, // or 'node16'
})Test Scripts
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:types": "vitest typecheck"
}
}Mocking
import { vi } from 'vitest'
vi.mock('fs', () => ({
readFileSync: vi.fn(() => 'mocked content'),
}))
// Spy on method
const spy = vi.spyOn(console, 'log')
expect(spy).toHaveBeenCalledWith('expected')Testing Plugins
Dogfood your own plugin in tests:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import MyPlugin from './src/vite'
export default defineConfig({
plugins: [
MyPlugin({ /* options */ }),
],
test: {
include: ['test/**/*.test.ts'],
},
})Related skills
How it compares
Use ts-library when package.json exports and dual-format dist layout matter, not just tsconfig strictness tweaks.
FAQ
Who is ts-library for?
Developers and software engineers working with ts-library patterns described in the skill documentation.
When should I use ts-library?
When Use when authoring TypeScript libraries or npm packages - covers project setup, package.json exports, build tooling tsdown/unbuild , API design patterns, type inference tricks, tes.
Is ts-library safe to install?
Review the Security Audits panel on this page before installing in production.