I Published My First npm Package — Here's Everything I Wish I Knew
Publishing to npm is easier than you think. But there are landmines I wish someone had warned me about.
The Idea
I built a small utility function at work:
// deep-merge.js — merge nested objects deeply
function deepMerge(target, ...sources) {
for (const source of sources) {
for (const key of Object.keys(source)) {
const targetVal = target[key];
const sourceVal = source[key];
if (sourceVal && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) {
if (!target[key]) target[key] = {};
deepMerge(target[key], sourceVal);
} else {
target[key] = sourceVal;
}
}
}
return target;
}
module.exports = { deepMerge };
"Someone else probably needs this," I thought. "Let me publish it."
Step 1: Package Setup
mkdir deep-merge-utils && cd deep-merge-utils
npm init -y
# Install dev dependencies
npm install -D typescript tsup jest @types/node
package.json — the critical file:
{
"name": "@armorbreak/deep-merge",
"version": "1.0.0",
"description": "Deep merge objects with type safety and array handling options",
"type": "module", // ESM first!
"main": "./dist/index.cjs", // CJS entry (for compatibility)
"module": "./dist/index.js", // ESM entry
"types": "./dist/index.d.ts", // TypeScript types
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
}
},
"files": ["dist"],
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts",
"test": "node --test",
"prepublishOnly": "npm run build && npm test"
},
"engines": { "node": ">=16" },
"keywords": ["deep-merge", "merge", "object", "utility"],
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/armorbreak001/deep-merge"
},
"author": "Alex Chen <contact@agentvote.cc>",
"publishConfig": {
"access": "public" // CRITICAL for scoped packages!
}
}
Mistake #1: I used @scope/package-name but forgot "access": "public". npm defaults scoped packages to private. Got this error on first publish: 402 Payment Required. 🤦
Step 2: Write the Code Properly
// src/index.ts — the actual package code
export interface DeepMergeOptions {
/** How to handle arrays: 'replace' (default) or 'concat' */
arrayMode?: 'replace' | 'concat';
/** Clone sources instead of mutating */
immutable?: boolean;
/** Max depth to prevent circular reference crashes */
maxDepth?: number;
}
const DEFAULTS: Required<DeepMergeOptions> = {
arrayMode: 'replace',
immutable: true,
maxDepth: 10,
};
/**
* Deeply merges source objects into target.
*
* @example
* ```
ts
* deepMerge({ a: { x: 1 } }, { a: { y: 2 } })
* // => { a: { x: 1, y: 2 } }
*
*/
export function deepMerge>(
target: T,
...sources: Partial[]
): T {
return _deepMerge({}, [target, ...sources], 0);
}
function _deepMerge(
target: any,
sources: any[],
depth: number,
options: DeepMergeOptions = {}
): any {
const opts = { ...DEFAULTS, ...options };
if (depth > opts.maxDepth) {
throw new Error(Max depth (${opts.maxDepth}) exceeded);
}
const result = opts.immutable ? {} : target;
for (const source of sources) {
if (!source || typeof source !== 'object') continue;
for (const key of Object.keys(source)) {
const sourceVal = source[key];
if (sourceVal && typeof sourceVal === 'object' && !Array.isArray(sourceVal)) {
if (!result[key] || typeof result[key] !== 'object' || Array.isArray(result[key])) {
result[key] = opts.immutable ? {} : result[key];
}
result[key] = _deepMerge(result[key], [sourceVal], depth + 1, opts);
} else if (Array.isArray(sourceVal)) {
const existing = Array.isArray(result[key]) ? result[key] : [];
result[key] = opts.arrayMode === 'concat'
? [...existing, ...sourceVal]
: [...sourceVal];
} else {
result[key] = sourceVal;
}
}
}
return result;
}
/** Shallow clone of an object */
export function clone(obj: T): T {
return _deepMerge({}, [obj as any], 0, { immutable: true });
}
/** Merge with array concatenation */
export function concatMerge>(
target: T,
...sources: Partial[]
): T {
return _deepMerge({}, [target, ...sources], 0, { arrayMode: 'concat', immutable: true });
}
## Step 3: Tests (Non-Negotiable)
```typescript
// tests/deep-merge.test.ts
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { deepMerge, clone, concatMerge } from '../src/index.js';
describe('deepMerge', () => {
it('merges simple objects', () => {
assert.deepEqual(
deepMerge({ a: 1 }, { b: 2 }),
{ a: 1, b: 2 }
);
});
it('deeply nests', () => {
assert.deepEqual(
deepMerge({ a: { x: 1 } }, { a: { y: 2 } }),
{ a: { x: 1, y: 2 } }
);
});
it('replaces arrays by default', () => {
assert.deepEqual(
deepMerge({ items: [1, 2] }, { items: [3, 4] }),
{ items: [3, 4] }
);
});
it('concatenates arrays with option', () => {
assert.deepEqual(
deepMerge({ items: [1, 2] }, { items: [3, 4] }, { arrayMode: 'concat' }),
{ items: [1, 2, 3, 4] }
);
});
it('does not mutate original objects by default', () => {
const original = { a: { b: 1 } };
const result = deepMerge(original, { a: { c: 2 } });
assert.deepEqual(original.a, { b: 1 }); // Original unchanged!
});
it('handles empty sources', () => {
assert.deepEqual(deepMerge({ a: 1 }), { a: 1 });
assert.deepEqual(deepMerge({}, {}, {}), {});
});
it('throws on max depth exceeded', () => {
const circular: any = {};
circular.self = circular;
assert.throws(() => deepMerge({}, circular));
});
});
Run with: npm test
Step 4: Build & Test Locally
# Build
npm run build
# Output:
# dist/index.cjs (CommonJS)
# dist/index.js (ESM)
# dist/index.d.ts (TypeScript types)
# Test locally before publishing
npm link
# In another project:
# npm link @armorbreak/deep-merge
# import { deepMerge } from '@armorbreak/deep-merge';
Step 5: README.md (The Most Important File!)
# @armorbreak/deep-merge
[](https://www.npmjs.com/package/@armorbreak/deep-merge)
[](./LICENSE)
[]()
> Deep merge JavaScript objects with type safety, array handling options, and immutability.
## Install
\`\`\`bash
npm install @armorbreak/deep-merge
\`\`\`
## Quick Start
\`\`\`typescript
import { deepMerge } from '@armorbreak/deep-merge';
const config = deepMerge(
{ database: { host: 'localhost', port: 5432 } },
{ database: { port: 3306, ssl: true } },
{ logging: { level: 'debug' } }
);
// Result:
// {
// database: { host: 'localhost', port: 3306, ssl: true },
// logging: { level: 'debug' }
// }
\`\`\`
## API
### \`deepMerge(target, ...sources)\`
Deeply merges source objects into target.
### \`clone(obj)\`
Creates a deep copy.
### \`concatMerge(target, ...sources)\`
Like deepMerge but concatenates arrays instead of replacing.
## Options
| Option | Default | Description |
|--------|---------|-------------|
| \`arrayMode\` | \`'replace'\` | How to handle arrays |
| \`immutable\` | \`true\` | Don't mutate inputs |
| \`maxDepth\` | \`10\` | Prevent infinite loops |
## Why Another Merge Library?
- **TypeScript native**: Written in TS, ships with types
- **Zero dependencies**: No lodash, no nothing
- **Tiny**: ~1KB minified + gzipped
- **Tree-shakeable**: ESM output, import what you need
- **Tested**: 100% coverage on happy path
## License
MIT © Alex Chen
Mistake #2: My first README was one paragraph. Zero installs for two weeks. Rewrote with examples, badges, and API docs → installs started coming in.
Step 6: Publish!
# Login (one-time setup)
npm login
# Username: your npm username
# Password: your password (or OTP)
# Email: your email
# Dry run (validates everything without actually publishing)
npm publish --dry-run
# If all good:
npm publish
# 🎉 Your package is live!
# https://www.npmjs.com/package/@armorbreak/deep-merge
What Happens After Publishing
Week 1: Crickets
Downloads: 12 (probably all me testing)
Week 2: I Added Keywords
Added merge, deep-merge, object, utility, lodash to keywords. Downloads: 23.
Week 3: I Wrote a Dev.to Article About It
This article! Downloads jumped to 87 that week.
Month 1 Stats
Total downloads: 342
Stars on GitHub: 8
Issues: 1 (feature request!)
Dependents: 2 (people using it in their projects!!)
Mistakes I Made (So You Don't Have To)
| # | Mistake | Fix |
|---|---|---|
| 1 | Forgot "access": "public" for scoped package |
Add to package.json |
| 2 | One-line README | Write proper docs with examples |
| 3 | No tests published | Include tests in repo, show they pass |
| 4 | Only shipped ESM | Ship both CJS + ESM for compatibility |
| 5 | Used files: ["*"]
|
Use files: ["dist"] to keep package small |
| 6 | Version 1.0.0 was buggy | Start at 0.1.0, save 1.0.0 for stable |
| 7 | Didn't respond to first issue | Engage early adopters immediately |
| 8 | Published at 6 PM Friday | Publish Monday morning for visibility |
Tips for Getting Your First Downloads
- Write about it. A single article drove more traffic than SEO alone.
- Add useful keywords. People search by keyword, not just name.
- Include badges. They signal quality (version, license, tests).
- Respond to issues fast. Happy users become advocates.
- Keep it small. <5KB gzipped is ideal for utility packages.
- Ship both CJS and ESM. Maximum compatibility.
What's stopping you from publishing your first npm package?
Follow @armorbreak for more developer content.
Top comments (0)