End-to-end TypeScript project setup for 2026.

Layout (library)

mylib/
├── package.json
├── tsconfig.json
├── tsup.config.ts
├── vitest.config.ts
├── eslint.config.js
├── .prettierrc
├── .github/workflows/ci.yml
├── src/
│   ├── index.ts
│   └── ...
└── test/
    └── *.test.ts

Init

npm init -y
npm i -D typescript tsup tsx vitest @vitest/coverage-v8 \
  eslint @eslint/js typescript-eslint eslint-config-prettier \
  prettier

package.json (library)

{
  "name": "mylib",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" }
    }
  },
  "files": ["dist"],
  "sideEffects": false,
  "scripts": {
    "dev": "tsup --watch",
    "build": "tsup",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:run": "vitest run --coverage",
    "lint": "eslint .",
    "format": "prettier --write .",
    "prepublishOnly": "npm run build"
  },
  "engines": { "node": ">=20" }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "lib": ["ES2023"],
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "noFallthroughCasesInSwitch": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true,
    "outDir": "dist",
    "noEmit": true
  },
  "include": ["src/**/*", "test/**/*"]
}

(noEmit: true since tsup emits — tsc only type-checks.)

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm", "cjs"],
  dts: true,
  clean: true,
  target: "node20",
  sourcemap: true,
  splitting: false,
});

vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      thresholds: {
        lines: 80, branches: 80, functions: 80, statements: 80,
      },
      include: ["src/**"],
    },
  },
});

eslint.config.js (flat)

import js from "@eslint/js";
import ts from "typescript-eslint";
import prettier from "eslint-config-prettier";

export default ts.config(
  js.configs.recommended,
  ...ts.configs.strictTypeChecked,
  ...ts.configs.stylisticTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  {
    rules: {
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
    },
  },
  prettier,
  { ignores: ["dist", "coverage", "node_modules"] },
);

.prettierrc

{
  "semi": true,
  "singleQuote": false,
  "trailingComma": "all",
  "printWidth": 100
}

GitHub Actions

.github/workflows/ci.yml:

name: CI
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node: ["20", "22"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint
      - run: npm run test:run
      - run: npm run build

.gitignore

node_modules/
dist/
coverage/
.env
*.log
.DS_Store

.npmignore (or use “files” in package.json)

# Better: use "files" in package.json — whitelist > blacklist

Editor

.editorconfig:

root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
insert_final_newline = true

.vscode/settings.json:

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "typescript.tsdk": "node_modules/typescript/lib"
}

Pre-commit (lint-staged + husky)

npm i -D husky lint-staged
npx husky init

.husky/pre-commit:

npx lint-staged

package.json:

{
  "lint-staged": {
    "*.{ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{json,md,yml}": ["prettier --write"]
  }
}

Publish

npm run build
npm publish --access public

For pre-releases:

npm version prerelease --preid=beta
npm publish --tag beta

Common conventions

  • src/ for source, dist/ for build output.
  • Single-source-of-truth: package.json with "exports".
  • tsc --noEmit for typecheck, tsup for build.
  • ESM-first; ship CJS only if consumers need it.
  • Pin Node version in engines + CI matrix.
  • Strict TS from day one.

Read this next

That’s 20 TypeScript cheatsheets. Next category: React.

If you want my full TS starter (lib + app variants wired), it’s at rajpoot.dev .


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .