DEV Community

Alex Chen
Alex Chen

Posted on

I Published My First npm Package — Here's Everything I Wish I Knew

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 };
Enter fullscreen mode Exit fullscreen mode

"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
Enter fullscreen mode Exit fullscreen mode

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!
  }
}
Enter fullscreen mode Exit fullscreen mode

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 } }
 *

Enter fullscreen mode Exit fullscreen mode

*/
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;
  }
}
Enter fullscreen mode Exit fullscreen mode

}

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));
  });
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

Step 5: README.md (The Most Important File!)

# @armorbreak/deep-merge

[![npm version](https://img.shields.io/npm/v/@armorbreak/deep-merge.svg)](https://www.npmjs.com/package/@armorbreak/deep-merge)
[![license](https://img.shields.io/npm/l/@armorbreak/deep-merge.svg)](./LICENSE)
[![tests](https://img.shields.io/badge/tests-passing-brightgreen.svg)]()

> 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!!)
Enter fullscreen mode Exit fullscreen mode

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

  1. Write about it. A single article drove more traffic than SEO alone.
  2. Add useful keywords. People search by keyword, not just name.
  3. Include badges. They signal quality (version, license, tests).
  4. Respond to issues fast. Happy users become advocates.
  5. Keep it small. <5KB gzipped is ideal for utility packages.
  6. 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)