TypeScriptパッケージを0から自作して公開しよう!

TypeScriptパッケージを0から自作して公開しよう!

公開: 2025年7月31日
更新: 2025年7月31日
9分で読めます

はじめに

「TypeScriptでライブラリを作ってNPMに公開してみたい!」

そんな想いを抱いているあなたのために、2025年の最新ベストプラクティスに基づいて、実際に手を動かしながらTypeScriptパッケージを作成し、NPMに公開するまでの全工程を解説します。

今回は「文字列操作ユーティリティ」パッケージを例に、以下の機能を持ったライブラリを作成します:

  • 文字列をURL-safeなslugに変換
  • 文字列の最初の文字を大文字に変換
  • 文字列を指定文字数で切り詰め
  • 完全な型安全性とテストカバレッジ

この記事を読み終える頃には、あなたも独自のTypeScriptパッケージをNPMに公開できるようになっているはずです!

📋 事前準備

まず、以下のツールがインストールされていることを確認してください:

# Node.js のバージョン確認(18以上推奨) node --version # npm のバージョン確認 npm --version # pnpm のインストール(推奨パッケージマネージャー) npm install -g pnpm

NPMアカウントも事前に作成しておきましょう:

  • npmjs.com でアカウント作成
  • ローカルでログイン: npm login

🚀 Step 1: プロジェクトの初期化

1-1. プロジェクトディレクトリの作成

mkdir string-utils-ts cd string-utils-ts # package.json の初期化 pnpm init

1-2. 基本的なディレクトリ構造の作成

mkdir -p src/__tests__ examples docs .github/workflows touch src/index.ts src/__tests__/index.test.ts README.md

最終的なディレクトリ構造:

string-utils-ts/
├── src/
│   ├── index.ts
│   └── __tests__/
│       └── index.test.ts
├── examples/
├── docs/
├── .github/
│   └── workflows/
├── package.json
└── README.md

1-3. 依存関係のインストール

# 開発依存関係のインストール pnpm add -D typescript @types/node pnpm add -D tsup # ビルドツール pnpm add -D jest @types/jest ts-jest # テスト pnpm add -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin pnpm add -D prettier eslint-config-prettier pnpm add -D @changesets/cli # バージョン管理 pnpm add -D typedoc # ドキュメント生成

🔧 Step 2: 設定ファイルの作成

2-1. TypeScript設定(tsconfig.json)

{ "compilerOptions": { "target": "ES2022", "module": "ESNext", "moduleResolution": "Bundler", "lib": ["ES2022"], "outDir": "./dist", "rootDir": "./src", "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "noImplicitAny": true, "strictNullChecks": true, "noImplicitReturns": true, "noUnusedLocals": true, "noUnusedParameters": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "incremental": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts"] }

2-2. ビルド設定(tsup.config.ts)

import { defineConfig } from 'tsup'; export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, sourcemap: true, clean: true, splitting: false, minify: false, target: 'es2022', outExtension({ format }) { return { js: format === 'cjs' ? '.cjs' : '.mjs' }; } });

2-3. テスト設定(jest.config.js)

module.exports = { preset: 'ts-jest', testEnvironment: 'node', roots: ['<rootDir>/src'], testMatch: ['**/__tests__/**/*.test.ts'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.d.ts', '!src/**/__tests__/**' ], coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } } };

2-4. ESLint設定(.eslintrc.json)

{ "extends": [ "eslint:recommended", "@typescript-eslint/recommended", "prettier" ], "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2022, "sourceType": "module", "project": "./tsconfig.json" }, "plugins": ["@typescript-eslint"], "rules": { "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], "@typescript-eslint/no-explicit-any": "error", "@typescript-eslint/prefer-unknown-over-any": "error" } }

2-5. Prettier設定(.prettierrc)

{ "semi": true, "trailingComma": "es5", "singleQuote": true, "printWidth": 80, "tabWidth": 2, "useTabs": false }

💻 Step 3: メインパッケージの実装

3-1. 型定義の作成(src/types.ts)

/** * Options for the slugify function */ export interface SlugifyOptions { /** Separator character (default: '-') */ separator?: string; /** Convert to lowercase (default: true) */ lowercase?: boolean; /** Remove special characters (default: true) */ strict?: boolean; } /** * Options for the truncate function */ export interface TruncateOptions { /** Maximum length of the string */ length: number; /** String to append when truncated (default: '...') */ omission?: string; /** Whether to break words (default: false) */ breakWords?: boolean; } /** * Validation result type */ export interface ValidationResult<T> { isValid: boolean; value?: T; error?: string; }

3-2. 文字列ユーティリティの実装(src/string-utils.ts)

import type { SlugifyOptions, TruncateOptions } from './types'; /** * Converts a string to a URL-friendly slug * * @param input - The input string to convert * @param options - Configuration options * @returns A URL-safe slug string * * @example * ```typescript * slugify('Hello World!'); // 'hello-world' * slugify('TypeScript Rocks', { separator: '_' }); // 'typescript_rocks' * ``` */ export function slugify(input: string, options: SlugifyOptions = {}): string { if (!input || typeof input !== 'string') { return ''; } const { separator = '-', lowercase = true, strict = true } = options; let result = input.trim(); // Convert to lowercase if specified if (lowercase) { result = result.toLowerCase(); } // Remove or replace special characters if (strict) { // Remove all non-alphanumeric characters except spaces and hyphens result = result.replace(/[^\w\s-]/g, ''); } // Replace spaces and multiple separators with single separator result = result .replace(/\s+/g, separator) .replace(new RegExp(`${separator}+`, 'g'), separator); // Remove leading and trailing separators result = result.replace(new RegExp(`^${separator}+|${separator}+$`, 'g'), ''); return result; } /** * Capitalizes the first letter of each word in a string * * @param input - The input string * @returns String with capitalized words * * @example * ```typescript * capitalize('hello world'); // 'Hello World' * capitalize('typeScript'); // 'TypeScript' * ``` */ export function capitalize(input: string): string { if (!input || typeof input !== 'string') { return ''; } return input .split(' ') .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } /** * Truncates a string to a specified length * * @param input - The input string * @param options - Truncation options * @returns Truncated string * * @example * ```typescript * truncate('This is a long text', { length: 10 }); // 'This is...' * truncate('Hello World', { length: 8, omission: '~' }); // 'Hello W~' * ``` */ export function truncate(input: string, options: TruncateOptions): string { if (!input || typeof input !== 'string') { return ''; } const { length, omission = '...', breakWords = false } = options; if (input.length <= length) { return input; } const maxLength = length - omission.length; if (maxLength <= 0) { return omission.slice(0, length); } let result = input.slice(0, maxLength); if (!breakWords) { const lastSpaceIndex = result.lastIndexOf(' '); if (lastSpaceIndex > 0) { result = result.slice(0, lastSpaceIndex); } } return result + omission; } /** * Checks if a string is a valid email address * * @param input - The input string * @returns Validation result * * @example * ```typescript * isValidEmail('[email protected]'); // { isValid: true, value: '[email protected]' } * isValidEmail('invalid-email'); // { isValid: false, error: 'Invalid email format' } * ``` */ export function isValidEmail(input: string): { isValid: boolean; value?: string; error?: string } { if (!input || typeof input !== 'string') { return { isValid: false, error: 'Input must be a non-empty string' }; } const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (emailRegex.test(input.trim())) { return { isValid: true, value: input.trim() }; } return { isValid: false, error: 'Invalid email format' }; } /** * Removes extra whitespace and normalizes spacing * * @param input - The input string * @returns Cleaned string * * @example * ```typescript * cleanWhitespace(' hello world '); // 'hello world' * ``` */ export function cleanWhitespace(input: string): string { if (!input || typeof input !== 'string') { return ''; } return input.trim().replace(/\s+/g, ' '); }

3-3. メインエクスポートファイル(src/index.ts)

// Main exports export { slugify, capitalize, truncate, isValidEmail, cleanWhitespace } from './string-utils'; // Type exports export type { SlugifyOptions, TruncateOptions, ValidationResult } from './types'; // Package version (will be replaced during build) export const VERSION = '1.0.0';

🧪 Step 4: テストの実装

4-1. 包括的テストスイート(src/tests/index.test.ts)

import { slugify, capitalize, truncate, isValidEmail, cleanWhitespace } from '../index'; describe('String Utils', () => { describe('slugify', () => { it('should convert string to slug', () => { expect(slugify('Hello World')).toBe('hello-world'); expect(slugify('TypeScript Rocks!')).toBe('typescript-rocks'); }); it('should handle custom separator', () => { expect(slugify('Hello World', { separator: '_' })).toBe('hello_world'); expect(slugify('Test-Case', { separator: '.' })).toBe('test.case'); }); it('should handle case sensitivity', () => { expect(slugify('Hello World', { lowercase: false })).toBe('Hello-World'); }); it('should handle empty input', () => { expect(slugify('')).toBe(''); expect(slugify(' ')).toBe(''); }); it('should handle special characters in strict mode', () => { expect(slugify('Hello@World#Test!')).toBe('hello-world-test'); }); it('should preserve some characters in non-strict mode', () => { expect(slugify('Hello@World', { strict: false })).toBe('hello@world'); }); }); describe('capitalize', () => { it('should capitalize words', () => { expect(capitalize('hello world')).toBe('Hello World'); expect(capitalize('typeScript developer')).toBe('Typescript Developer'); }); it('should handle single word', () => { expect(capitalize('hello')).toBe('Hello'); expect(capitalize('WORLD')).toBe('World'); }); it('should handle empty input', () => { expect(capitalize('')).toBe(''); }); it('should handle edge cases', () => { expect(capitalize('a')).toBe('A'); expect(capitalize('123 test')).toBe('123 Test'); }); }); describe('truncate', () => { it('should truncate long strings', () => { expect(truncate('This is a long string', { length: 10 })) .toBe('This is...'); }); it('should not truncate short strings', () => { expect(truncate('Short', { length: 10 })).toBe('Short'); }); it('should use custom omission', () => { expect(truncate('Hello World', { length: 8, omission: '~' })) .toBe('Hello W~'); }); it('should handle word breaking', () => { expect(truncate('Hello World', { length: 8, breakWords: true })) .toBe('Hello...'); expect(truncate('Hello World', { length: 8, breakWords: false })) .toBe('Hello...'); }); it('should handle edge cases', () => { expect(truncate('', { length: 5 })).toBe(''); expect(truncate('Test', { length: 2, omission: '...' })).toBe('..'); }); }); describe('isValidEmail', () => { it('should validate correct emails', () => { const result = isValidEmail('[email protected]'); expect(result.isValid).toBe(true); expect(result.value).toBe('[email protected]'); }); it('should reject invalid emails', () => { expect(isValidEmail('invalid-email').isValid).toBe(false); expect(isValidEmail('test@').isValid).toBe(false); expect(isValidEmail('@example.com').isValid).toBe(false); }); it('should handle empty input', () => { const result = isValidEmail(''); expect(result.isValid).toBe(false); expect(result.error).toBe('Input must be a non-empty string'); }); it('should trim whitespace', () => { const result = isValidEmail(' [email protected] '); expect(result.isValid).toBe(true); expect(result.value).toBe('[email protected]'); }); }); describe('cleanWhitespace', () => { it('should clean extra whitespace', () => { expect(cleanWhitespace(' hello world ')).toBe('hello world'); }); it('should handle tabs and newlines', () => { expect(cleanWhitespace('hello\t\nworld')).toBe('hello world'); }); it('should handle empty input', () => { expect(cleanWhitespace('')).toBe(''); expect(cleanWhitespace(' ')).toBe(''); }); }); });

📝 Step 5: package.json の完成

{ "name": "@yourusername/string-utils-ts", "version": "1.0.0", "description": "A modern TypeScript string utility library with full type safety", "keywords": [ "typescript", "string", "utilities", "slug", "validation", "text" ], "author": "Your Name <[email protected]>", "license": "MIT", "homepage": "https://github.com/yourusername/string-utils-ts#readme", "repository": { "type": "git", "url": "git+https://github.com/yourusername/string-utils-ts.git" }, "bugs": { "url": "https://github.com/yourusername/string-utils-ts/issues" }, "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./package.json": "./package.json" }, "files": [ "dist", "README.md", "LICENSE" ], "scripts": { "build": "tsup", "dev": "tsup --watch", "test": "jest", "test:watch": "jest --watch", "test:coverage": "jest --coverage", "lint": "eslint src/**/*.ts", "lint:fix": "eslint src/**/*.ts --fix", "format": "prettier --write src/**/*.ts", "format:check": "prettier --check src/**/*.ts", "type-check": "tsc --noEmit", "docs": "typedoc src/index.ts --out docs", "clean": "rm -rf dist coverage docs", "prepare": "npm run build", "prepack": "npm run clean && npm run build && npm run test", "changeset": "changeset", "version": "changeset version", "release": "npm run prepack && changeset publish" }, "devDependencies": { "@changesets/cli": "^2.27.0", "@types/jest": "^29.5.0", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.57.0", "eslint-config-prettier": "^9.0.0", "jest": "^29.7.0", "prettier": "^3.2.0", "ts-jest": "^29.1.0", "tsup": "^8.0.0", "typedoc": "^0.25.0", "typescript": "^5.5.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }

📚 Step 6: ドキュメンテーションの作成

6-1. README.md

# @yourusername/string-utils-ts [![npm version](https://badge.fury.io/js/%40yourusername%2Fstring-utils-ts.svg)](https://www.npmjs.com/package/@yourusername/string-utils-ts) [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/) [![Test Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen.svg)](https://github.com/yourusername/string-utils-ts) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) A modern TypeScript string utility library providing type-safe string manipulation functions. ## ✨ Features - 🚀 **Modern TypeScript** - Full type safety with TypeScript 5.5+ - 📦 **ESM & CJS** - Dual module format support - 🎯 **Tree-shakeable** - Import only what you need - ✅ **100% Test Coverage** - Reliable and well-tested - 📖 **Comprehensive Docs** - Full API documentation - 🔧 **Zero Dependencies** - Lightweight and fast ## 🤝 Contributing Contributions are welcome! Please read our [Contributing Guide](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests. ## 📄 License This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. ## 🙏 Acknowledgments - Inspired by popular string utility libraries - Built with modern TypeScript best practices - Tested with Jest for reliability

6-2. 使用例ファイル(examples/basic-usage.ts)

import { slugify, capitalize, truncate, isValidEmail, cleanWhitespace } from '../src/index'; // Basic usage examples console.log('=== Basic String Utils Examples ===\n'); // Slugify examples console.log('Slugify:'); console.log(`slugify('Hello World!')`, '->', slugify('Hello World!')); console.log(`slugify('TypeScript & React')`, '->', slugify('TypeScript & React')); console.log(`slugify('Custom_Separator', { separator: '.' })`, '->', slugify('Custom_Separator', { separator: '.' })); // Capitalize examples console.log('\nCapitalize:'); console.log(`capitalize('hello world')`, '->', capitalize('hello world')); console.log(`capitalize('javascript developer')`, '->', capitalize('javascript developer')); // Truncate examples console.log('\nTruncate:'); console.log(`truncate('This is a very long sentence', { length: 15 })`, '->', truncate('This is a very long sentence', { length: 15 })); console.log(`truncate('Short text', { length: 20 })`, '->', truncate('Short text', { length: 20 })); // Email validation examples console.log('\nEmail Validation:'); console.log(`isValidEmail('[email protected]')`, '->', isValidEmail('[email protected]')); console.log(`isValidEmail('invalid.email')`, '->', isValidEmail('invalid.email')); // Clean whitespace examples console.log('\nClean Whitespace:'); console.log(`cleanWhitespace(' hello world ')`, '->', `"${cleanWhitespace(' hello world ')}"`);

🔨 Step 7: ビルドとテストの実行

7-1. 初回ビルドとテスト

# 依存関係のインストール(まだの場合) pnpm install # TypeScript型チェック pnpm type-check # テストの実行 pnpm test # カバレッジ付きテスト pnpm test:coverage # リントチェック pnpm lint # コードフォーマット pnpm format # ビルド実行 pnpm build

7-2. ビルド出力の確認

ビルド後、dist/ ディレクトリに以下のファイルが生成されます:

dist/
├── index.cjs      # CommonJS版
├── index.mjs      # ES Module版
├── index.d.ts     # TypeScript定義ファイル
├── index.d.ts.map # 定義ファイルのソースマップ
├── index.mjs.map  # ES Moduleのソースマップ
└── index.cjs.map  # CommonJSのソースマップ

🚀 Step 8: NPM公開の準備

8-1. 公開前チェック

# パッケージ内容の確認 npm pack --dry-run # publint による検証(推奨) npx publint # are-the-types-wrong による型チェック(推奨) npx are-the-types-wrong

8-2. changeset による初期化

# changeset の初期化 npx @changesets/cli init # 最初のchangeset作成 npx changeset

changeset作成時の選択:

  • パッケージ選択: @yourusername/string-utils-ts
  • 変更タイプ: major (初回リリースのため)
  • 変更内容の説明: "Initial release of string utilities"

8-3. バージョン更新と公開

# バージョン更新 npx changeset version # 最終ビルドとテスト pnpm prepack # NPMに公開 npm publish

🔄 Step 9: 継続的インテグレーションの設定

9-1. GitHub Actions ワークフロー(.github/workflows/ci.yml)

name: CI/CD on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Type check run: pnpm type-check - name: Lint run: pnpm lint - name: Test run: pnpm test:coverage - name: Build run: pnpm build release: needs: test runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Install pnpm uses: pnpm/action-setup@v2 with: version: 8 - name: Install dependencies run: pnpm install --frozen-lockfile - name: Build run: pnpm build - name: Create Release PR or Publish uses: changesets/action@v1 with: publish: pnpm release commit: 'chore: release packages' title: 'chore: release packages' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

📈 Step 10: パッケージの保守と更新

10-1. 新機能の追加

新しい機能を追加する場合の手順:

  1. 機能の実装
  2. テストの追加
  3. ドキュメントの更新
  4. changesetの作成
# 新機能のテスト pnpm test # changeset作成 npx changeset

10-2. バージョン管理のベストプラクティス

  • patch: バグ修正
  • minor: 新機能追加(後方互換性あり)
  • major: 破壊的変更

10-3. ドキュメント自動生成

# TypeDoc による API ドキュメント生成 pnpm docs # 生成されたドキュメントの確認 open docs/index.html

🎉 完成!

おめでとうございます!これで以下のすべてが完了しました:

モダンなTypeScriptパッケージの作成
完全な型安全性の実装
包括的なテストスイート
ESM/CJS デュアル対応
自動化されたビルドプロセス
NPM公開の準備
継続的インテグレーション
ドキュメンテーション

🔗 参考リンク

💡 次のステップ

このガイドで基礎を習得したあなたは、以下にチャレンジしてみましょう:

  1. より高度な型機能の活用 - Template Literal Types、Conditional Types
  2. パッケージのモノレポ化 - 複数の関連パッケージの管理
  3. パフォーマンス最適化 - バンドルサイズの削減
  4. 国際化対応 - 多言語対応ユーティリティ
  5. ブラウザ対応 - Web API の活用

あなたの創造性を活かして、素晴らしいTypeScriptパッケージを世界に届けてください!🚀

コメントを投稿

メールアドレスは公開されません

最大1000文字まで。マークダウン記法(**太字**、*斜体*、`コード`など)が使用できます

投稿されたコメントは管理者による承認後に表示される場合があります。 不適切な内容は削除される可能性があります。

コメント (0件)

関連記事