JSON Configuration File Management: tsconfig.json, package.json, JSON5 & Schema Validation
Last updated:
JSON configuration files are the standard for JavaScript/TypeScript tooling — package.json defines project metadata and scripts, tsconfig.json sets TypeScript compiler options, .eslintrc.json configures linting rules, and jest.config.json controls test runner behavior. JSON config files cannot contain comments (standard JSON forbids them) — use JSON5 or JSONC (VS Code's comment-supporting JSON variant) for configs that need inline documentation; TypeScript's tsconfig.json uses JSONC and supports // comments. The $schema field adds editor autocomplete: "$schema": "https://json.schemastore.org/tsconfig" triggers VS Code IntelliSense for all tsconfig.json options. This guide covers the essential package.json fields (main, module, exports, types, engines, scripts), tsconfig.json compiler options for strict mode, environment-specific configs with extends, JSON Schema for config validation, JSON5 vs JSONC differences, and merging configs with Object.assign() vs spread.
package.json: Essential Fields (main, module, exports, types, scripts)
package.json is the central manifest for every Node.js project — it declares dependencies, defines entry points for published packages, specifies the Node.js version requirement, and registers npm scripts. The entry point fields (main, module, exports, types) determine what consumers get when they import or require your package. Modern packages use exports instead of main: it supports dual CJS/ESM output, subpath exports, and internal file encapsulation. The engines field declares minimum Node.js and npm versions, and package managers like pnpm enforce it; packageManager (Corepack) pins the exact package manager version.
{
"$schema": "https://json.schemastore.org/package",
"name": "@myorg/my-library",
"version": "1.2.0",
"description": "A well-configured npm package",
"license": "MIT",
"author": "Your Name <you@example.com>",
// Legacy entry point — used by Node.js < 12 and old bundlers
"main": "./dist/index.cjs",
// ESM entry point — used by bundlers that respect this field (Rollup, Webpack 4)
"module": "./dist/index.mjs",
// TypeScript declaration file — used by tsc and editor type checking
"types": "./dist/index.d.ts",
// Modern entry points — overrides "main" in Node.js 12+, Webpack 5, Vite
"exports": {
// Default export: require("my-library") or import "my-library"
".": {
"types": "./dist/index.d.ts", // TypeScript consumers
"import": "./dist/index.mjs", // ESM: import { foo } from "my-library"
"require": "./dist/index.cjs" // CJS: const { foo } = require("my-library")
},
// Subpath export: import { helper } from "my-library/utils"
"./utils": {
"types": "./dist/utils.d.ts",
"import": "./dist/utils.mjs",
"require": "./dist/utils.cjs"
}
// Any path not listed here is BLOCKED — consumers cannot access internal files
// import "my-library/internal/secret" → ERR_PACKAGE_PATH_NOT_EXPORTED
},
// Files included when publishing to npm (whitelist)
"files": ["dist", "README.md", "LICENSE"],
// Node.js and npm version requirements
"engines": {
"node": ">=18.0.0",
"npm": ">=9.0.0"
},
// Corepack: pins exact package manager version
"packageManager": "pnpm@9.1.0",
// Scripts — run with npm run <name>
"scripts": {
"build": "tsc --project tsconfig.build.json",
"build:watch": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src --ext .ts,.tsx",
"typecheck": "tsc --noEmit",
"prepublish": "npm run build && npm run test"
},
// Runtime dependencies
"dependencies": {
"zod": "^3.22.0"
},
// Dev-only dependencies — not installed by consumers
"devDependencies": {
"typescript": "^5.4.0",
"vitest": "^1.5.0",
"@tsconfig/node20": "^20.1.4"
},
// Peer dependencies — must be installed by the consumer
"peerDependencies": {
"react": ">=18.0.0"
},
// Side effects: false = tree-shakeable (no module-level side effects)
"sideEffects": false
}The sideEffects field tells bundlers like Webpack and Rollup that importing any file from this package will not cause module-level side effects — enabling dead code elimination (tree-shaking) for unused exports. Set it to false for pure utility libraries; set it to an array of glob patterns for packages with CSS imports or polyfills that must not be removed: "sideEffects": ["**/*.css", "./src/polyfills.js"]. The prepublish script runs automatically before npm publish, ensuring the package is always built and tested before release.
tsconfig.json: Compiler Options and strict Mode
tsconfig.json controls how TypeScript compiles your project. The compilerOptions object is the most important section — it sets the ECMAScript target version, module system, output directory, and type checking strictness. The strict flag is a shorthand that enables eight individual strict checks at once: strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, noImplicitAny, noImplicitThis, alwaysStrict, and useUnknownInCatchVariables. Always enable strict: true for new projects — disabling it to silence errors creates technical debt.
// tsconfig.json — JSONC format, // comments are supported
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
// ── Output target ──────────────────────────────────────────────
"target": "ES2022", // JS version emitted: ES5, ES2015–ES2022, ESNext
"lib": ["ES2022", "DOM"], // Built-in type definitions to include
"module": "NodeNext", // Module format: CommonJS, ESNext, NodeNext
"moduleResolution": "NodeNext", // How imports are resolved (must match module)
// ── Output directories ─────────────────────────────────────────
"outDir": "./dist", // Where compiled JS files go
"rootDir": "./src", // Root of source files (mirrors structure in outDir)
"declaration": true, // Emit .d.ts declaration files
"declarationMap": true, // Emit .d.ts.map for source-level debugging
"sourceMap": true, // Emit .js.map for runtime debugging
// ── Strict mode (enable all for new projects) ──────────────────
"strict": true, // Enables 8 strict checks (see list below)
// Individual strict flags (all enabled by "strict": true):
// "strictNullChecks": true, — null/undefined not assignable to other types
// "strictFunctionTypes": true, — stricter function parameter type checking
// "strictBindCallApply": true, — typed bind/call/apply
// "strictPropertyInitialization": true — class properties must be initialized
// "noImplicitAny": true, — error on implicit any types
// "noImplicitThis": true, — error on implicit this
// "alwaysStrict": true, — emit "use strict" in all files
// "useUnknownInCatchVariables": true — catch variables are unknown, not any
// ── Additional strictness (beyond strict mode) ─────────────────
"noUncheckedIndexedAccess": true, // arr[0] has type T | undefined (not T)
"noImplicitOverride": true, // class overrides must use override keyword
"noPropertyAccessFromIndexSignature": true, // require bracket notation for index sigs
"exactOptionalPropertyTypes": true, // { x?: string } rejects { x: undefined }
// ── Error reporting ────────────────────────────────────────────
"noUnusedLocals": true, // Error on declared but unused local variables
"noUnusedParameters": true, // Error on unused function parameters
"noFallthroughCasesInSwitch": true, // Error on switch fallthrough
// ── Module resolution ──────────────────────────────────────────
"baseUrl": ".", // Base for non-relative imports
"paths": { // Path aliases (requires baseUrl)
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"]
},
"resolveJsonModule": true, // Allow import data from "./data.json"
"esModuleInterop": true, // CommonJS default import interop
"allowSyntheticDefaultImports": true, // import React from "react" (no * as)
// ── Project references ─────────────────────────────────────────
"composite": true, // Required for project references (speeds up builds)
"incremental": true, // Cache compilation state in .tsbuildinfo
"tsBuildInfoFile": ".tsbuildinfo",
// ── Type checking only (no emit) ───────────────────────────────
// "noEmit": true, // Type check only; let bundler handle output
// ── JSX (for React projects) ───────────────────────────────────
// "jsx": "react-jsx", // React 17+ JSX transform (no import needed)
// "jsxImportSource": "react"
},
"include": ["src/**/*"], // Files to compile
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}noUncheckedIndexedAccess is the most impactful option not included in strict: true — it makes array indexing and object index signature access return T | undefined instead of T, catching out-of-bounds access bugs at compile time. Enable it in new projects from the start; retrofitting it is painful because it requires adding null checks throughout the codebase. The composite and incremental flags dramatically speed up TypeScript builds in monorepos by caching the compilation graph in a .tsbuildinfo file.
extends and Config Inheritance
The extends field in tsconfig.json, .eslintrc.json, and similar config formats enables base config inheritance — define shared settings once in a base file and override only what differs in each project or environment. TypeScript's extends performs a deep merge of compilerOptions: individual options from the extending file override matching options in the base, while non-conflicting options from both files are combined. The @tsconfig npm organization publishes community-maintained base configs for common environments, eliminating the need to hand-tune every compiler option.
// Install base configs from npm:
// npm install --save-dev @tsconfig/node20 @tsconfig/strictest
// tsconfig.base.json — shared settings for all packages in a monorepo
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
}
}
// tsconfig.json — development config, extends base
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
// tsconfig.test.json — test config (includes test files)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true, // Tests don't need output
"types": ["vitest/globals"]
},
"include": ["src/**/*", "tests/**/*"]
}
// tsconfig.build.json — production build (stricter, no test files)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false, // No source maps in production output
"noUnusedLocals": true,
"noUnusedParameters": true
}
}
// TypeScript 5.0+: extends array (multiple bases, later wins conflicts)
{
"extends": [
"@tsconfig/strictest/tsconfig.json", // maximum strict settings
"@tsconfig/node20/tsconfig.json", // Node 20 module resolution
"./tsconfig.paths.json" // path aliases (wins conflicts)
]
}
// .eslintrc.json — same extends pattern for ESLint
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/strict",
"plugin:@typescript-eslint/stylistic"
],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/consistent-type-imports": "warn"
}
}One critical gotcha: include, exclude, and files arrays in tsconfig.json are not merged — the extending file's arrays completely replace the base file's arrays. If your base tsconfig.json has "exclude": ["node_modules"] and your extending file omits exclude, the base value is inherited. But if the extending file sets "exclude": ["dist"], the node_modules exclusion from the base is dropped. Always explicitly set include and exclude in each project-level tsconfig.json rather than relying on base inheritance for these arrays.
Environment-Specific Config Files: development vs production
JavaScript applications typically need different configuration values for development, staging, and production environments — API base URLs, feature flags, log levels, and third-party service keys all vary by environment. The standard pattern is a base config file merged with an environment-specific override, selected at runtime using NODE_ENV. Keep sensitive values (API keys, secrets) in environment variables, never in committed JSON files — use JSON config files only for non-sensitive structural configuration.
// config/default.json — shared settings across all environments
{
"app": {
"name": "My Application",
"port": 3000,
"logLevel": "info"
},
"db": {
"poolSize": 10,
"connectionTimeout": 5000
},
"features": {
"darkMode": true,
"betaDashboard": false
}
}
// config/development.json — overrides for local development
{
"app": {
"logLevel": "debug" // verbose logging locally
},
"db": {
"host": "localhost",
"port": 5432,
"name": "myapp_dev"
},
"features": {
"betaDashboard": true // enable beta features in dev
}
}
// config/production.json — overrides for production
{
"app": {
"port": 8080,
"logLevel": "warn" // less noise in production
},
"db": {
"poolSize": 50, // larger pool for production load
"connectionTimeout": 3000
}
}
// config/loader.ts — merge base + environment config
import { readFileSync } from "fs"
import { join } from "path"
function loadConfig() {
const env = process.env.NODE_ENV ?? "development"
const configDir = join(process.cwd(), "config")
const defaultConfig = JSON.parse(
readFileSync(join(configDir, "default.json"), "utf-8")
)
let envConfig = {}
try {
envConfig = JSON.parse(
readFileSync(join(configDir, `${env}.json`), "utf-8")
)
} catch {
console.warn(`No config file for environment: ${env}`)
}
// Deep merge: envConfig values override defaultConfig
return deepMerge(defaultConfig, envConfig)
}
// Deep merge helper — spread only does shallow merge
function deepMerge<T extends object>(base: T, override: Partial<T>): T {
const result = { ...base }
for (const key of Object.keys(override) as Array<keyof T>) {
const overrideVal = override[key]
const baseVal = base[key]
if (overrideVal && typeof overrideVal === "object" && !Array.isArray(overrideVal)
&& baseVal && typeof baseVal === "object") {
result[key] = deepMerge(baseVal as object, overrideVal as object) as T[keyof T]
} else if (overrideVal !== undefined) {
result[key] = overrideVal as T[keyof T]
}
}
return result
}
export const config = loadConfig()
// Use: import { config } from "./config/loader"
// config.db.host — "localhost" in dev, from env var in prod
// config.features.betaDashboard — true in dev, false in prodShallow spread ({ ...base, ...override }) only merges top-level keys — nested objects like db or features are completely replaced rather than merged. Use a recursive deep merge for nested config objects. For production database credentials, always read from environment variables at runtime (process.env.DATABASE_URL) and inject them into the merged config object rather than storing them in JSON files — committed JSON files end up in version history, which is a security risk even if the files are later deleted.
JSON5 and JSONC: Comments in JSON Configs
Standard JSON forbids comments — RFC 8259 defines no comment syntax. JSONC (JSON with Comments) and JSON5 both solve this for configuration use cases, but they are different formats with different feature sets and ecosystem support. JSONC is widely used in the VS Code ecosystem and TypeScript tooling; JSON5 is a more comprehensive superset with additional syntax features. Choosing between them depends on which tools need to parse your config files.
// ── JSONC (JSON with Comments) ────────────────────────────────────
// Supported by: tsconfig.json, .vscode/settings.json, jest.config.json (via jest-runner)
// Parse in Node.js: npm install jsonc-parser
// .vscode/settings.json (JSONC)
{
// Editor settings
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
/* TypeScript settings */
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "relative"
}
// Parse JSONC programmatically:
import { parse } from "jsonc-parser"
import { readFileSync } from "fs"
const raw = readFileSync("tsconfig.json", "utf-8")
const config = parse(raw) // comments stripped, returns plain object
// parse() from jsonc-parser also tolerates trailing commas (VS Code's dialect)
// ── JSON5 ─────────────────────────────────────────────────────────
// JSON5 features: // comments, /* block comments */, trailing commas,
// unquoted keys, single-quoted strings, hex numbers, leading decimal point
// npm install json5
// config.json5
{
// Application settings
name: "my-app", // unquoted key
version: '1.0.0', // single-quoted string
port: 3000,
features: {
darkMode: true,
betaDashboard: false, // trailing comma — no error in JSON5
},
/*
* Database configuration
* Override with DATABASE_URL in production
*/
db: {
host: "localhost",
port: 5432,
timeout: 0x1388, // hex literal: 5000ms
},
}
// Parse JSON5 in Node.js:
import JSON5 from "json5"
import { readFileSync } from "fs"
const raw = readFileSync("config.json5", "utf-8")
const config = JSON5.parse(raw)
// JSON5.stringify() — serialize back to JSON5 format
const output = JSON5.stringify(config, null, 2)
// Note: JSON5.stringify output uses double quotes (valid JSON5 but not JSON)
// ── Comparison table ───────────────────────────────────────────────
// Feature JSON JSONC JSON5
// ─────────────────────────────────────────────────
// // single-line comments ✗ ✓ ✓
// /* block comments */ ✗ ✓ ✓
// Trailing commas ✗ ✗* ✓ (*VS Code tolerates them)
// Unquoted keys ✗ ✗ ✓
// Single-quoted strings ✗ ✗ ✓
// Hex number literals ✗ ✗ ✓
// Multi-line strings ✗ ✗ ✓
// Native browser support ✓ ✗ ✗
// Node.js built-in support ✓ ✗ ✗
// ── When to use each ──────────────────────────────────────────────
// Standard JSON: API responses, data files, any cross-platform exchange
// JSONC: tsconfig.json, VS Code configs, tool configs in VS Code ecosystem
// JSON5: Human-edited config files in Node.js projects where json5 is acceptableA common mistake is using JSON.parse() to read tsconfig.json in Node.js tooling scripts — it throws a SyntaxError because tsconfig.json contains JSONC comments. Always use jsonc-parser or TypeScript's own ts.parseConfigFileTextToJson() API to read tsconfig files programmatically. JSON5 is ideal for hand-maintained config files in Node.js applications, but avoid it for config files that need to be read by tools that do not support it — check your entire tool chain before adopting JSON5.
$schema: Editor Autocomplete with SchemaStore
The $schema field is a JSON Schema convention that tells editors where to find the schema for a given JSON file. VS Code reads this field and enables IntelliSense — property autocompletion, hover documentation, type validation, and inline error highlighting — without any additional configuration or plugin. SchemaStore (schemastore.org) is the community-maintained registry of JSON schemas for common config files, hosting over 500 schemas. For custom config formats, you can write your own JSON Schema and reference it via a local path or a hosted URL.
// ── SchemaStore schemas for common config files ───────────────────
// tsconfig.json
{ "$schema": "https://json.schemastore.org/tsconfig" }
// package.json
{ "$schema": "https://json.schemastore.org/package" }
// .eslintrc.json
{ "$schema": "https://json.schemastore.org/eslintrc" }
// jest.config.json
{ "$schema": "https://json.schemastore.org/jest" }
// .prettierrc.json
{ "$schema": "https://json.schemastore.org/prettierrc" }
// GitHub Actions workflow
{ "$schema": "https://json.schemastore.org/github-workflow" }
// docker-compose.yml (YAML, but SchemaStore covers it too)
// Add to .vscode/settings.json:
// "yaml.schemas": { "https://json.schemastore.org/docker-compose": "docker-compose*.yml" }
// ── Custom schema for your own config format ──────────────────────
// my-tool-schema.json — define the schema for your config format
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.com/my-tool-schema.json",
"title": "My Tool Config",
"type": "object",
"required": ["name", "output"],
"properties": {
"name": {
"type": "string",
"description": "Project name"
},
"output": {
"type": "string",
"description": "Output directory path",
"default": "./dist"
},
"watch": {
"type": "boolean",
"description": "Enable watch mode",
"default": false
},
"plugins": {
"type": "array",
"description": "List of plugin names to enable",
"items": { "type": "string" },
"default": []
}
},
"additionalProperties": false // reject unknown keys
}
// my-tool.config.json — consumer's config, references custom schema
{
"$schema": "./my-tool-schema.json", // local path
"name": "my-project",
"output": "./build", // IntelliSense works here
"watch": true,
"plugins": ["minify", "sourcemap"]
}
// ── VS Code: associate schemas with filename patterns ──────────────
// .vscode/settings.json — apply schema to all files matching a pattern
{
"json.schemas": [
{
"fileMatch": ["my-tool.config.json", "*.my-tool.json"],
"url": "./my-tool-schema.json" // local schema
},
{
"fileMatch": [".my-toolrc"],
"url": "https://example.com/my-tool-schema.json" // hosted schema
}
]
}The $schema field is purely advisory — JSON parsers and most tools ignore it entirely (it is just another string property). Its value is only consumed by JSON Language Server clients like VS Code and editors built on the Language Server Protocol. The SchemaStore schemas are auto-detected for well-known filenames: VS Code applies the tsconfig.json schema automatically to any file named tsconfig*.json, so the $schema field is optional for these files — but adding it explicitly makes the association visible and works in all LSP-compliant editors, not just VS Code.
Validating JSON Configs with JSON Schema and Ajv
Runtime validation of JSON config files prevents hard-to-debug failures when config values are missing, mistyped, or out of range. The standard validation stack is JSON Schema (the schema language) plus Ajv (the runtime validator). Validate config at application startup so errors surface immediately with descriptive messages rather than as cryptic runtime failures deep in the application. Combine Ajv with TypeScript type inference (via json-schema-to-ts or Zod) to get both runtime validation and compile-time type safety from a single schema definition. See the JSON Schema guide and JSON schema validation for full Schema syntax reference.
// npm install ajv ajv-formats
// npm install --save-dev @types/json-schema json-schema-to-ts
import Ajv, { type JSONSchemaType } from "ajv"
import addFormats from "ajv-formats"
import { readFileSync } from "fs"
// ── Define schema ──────────────────────────────────────────────────
interface AppConfig {
name: string
port: number
logLevel: "debug" | "info" | "warn" | "error"
db: {
host: string
port: number
name: string
poolSize: number
}
features: {
darkMode: boolean
betaDashboard: boolean
}
}
const schema: JSONSchemaType<AppConfig> = {
type: "object",
required: ["name", "port", "logLevel", "db", "features"],
properties: {
name: { type: "string", minLength: 1 },
port: { type: "integer", minimum: 1, maximum: 65535 },
logLevel: { type: "string", enum: ["debug", "info", "warn", "error"] },
db: {
type: "object",
required: ["host", "port", "name", "poolSize"],
properties: {
host: { type: "string" },
port: { type: "integer", minimum: 1, maximum: 65535 },
name: { type: "string" },
poolSize: { type: "integer", minimum: 1, maximum: 100 }
},
additionalProperties: false
},
features: {
type: "object",
required: ["darkMode", "betaDashboard"],
properties: {
darkMode: { type: "boolean" },
betaDashboard: { type: "boolean" }
},
additionalProperties: false
}
},
additionalProperties: false
}
// ── Set up Ajv ─────────────────────────────────────────────────────
const ajv = new Ajv({ allErrors: true }) // collect ALL errors, not just the first
addFormats(ajv) // adds "email", "uri", "date-time", etc.
const validate = ajv.compile<AppConfig>(schema)
// ── Load and validate config at startup ───────────────────────────
function loadConfig(): AppConfig {
const raw = readFileSync("config.json", "utf-8")
const data: unknown = JSON.parse(raw)
if (!validate(data)) {
const errors = validate.errors!
.map(e => ` ${e.instancePath || "(root)"} ${e.message}`)
.join("\n")
throw new Error(`Invalid config:\n${errors}`)
}
return data // TypeScript knows this is AppConfig — validate() is a type guard
}
// ── Error output example ───────────────────────────────────────────
// If config.json has port: "3000" (string instead of number) and missing db.host:
// Error: Invalid config:
// /port must be integer
// /db/host must be present
// ── CLI validation with ajv-cli ────────────────────────────────────
// package.json script:
// "validate:config": "ajv validate -s schema.json -d config.json"
// Run: npm run validate:config
// Use in CI to catch config errors before deploymentSetting allErrors: true in Ajv's constructor makes it collect all validation errors in a single pass rather than stopping at the first failure — essential for config validation where a developer needs to see all problems at once. The additionalProperties: false option in each schema object rejects unknown keys, catching typos like "portNumber" instead of "port" that would silently be ignored otherwise. For JSON best practices and TypeScript JSON patterns, combining Ajv's JSONSchemaType with TypeScript generics gives you a single source of truth that enforces both runtime and compile-time correctness.
Key Terms
- JSONC
- JSONC (JSON with Comments) is a superset of JSON that adds single-line (
//) and block (/* */) comment syntax. It is not an official standard but is widely used in the VS Code ecosystem —tsconfig.json,.vscode/settings.json,.vscode/launch.json, and many other tool configs use JSONC despite having.jsonextensions. Tools that parse JSONC strip comments before handing the content to a standard JSON parser, so the underlying data structure is identical to JSON. Parse JSONC in Node.js with thejsonc-parserpackage (published by Microsoft, used in VS Code). Unlike JSON5, JSONC does not add trailing commas, unquoted keys, or single-quoted strings — though VS Code's own JSONC parser tolerates trailing commas. - JSON5
- JSON5 is a community specification (json5.org) that extends JSON with human-friendly syntax: single-line and block comments, trailing commas in objects and arrays, unquoted object keys (when valid identifiers), single-quoted strings, multi-line strings with backslash continuation, hexadecimal integer literals, and leading/trailing decimal points in numbers. JSON5 is a strict superset of JSON — all valid JSON is valid JSON5. It is not natively supported in browsers or Node.js; use the
json5npm package (JSON5.parse()/JSON5.stringify()). Common use case: human-maintained config files in Node.js servers where thejson5dependency is acceptable. Not suitable for config files read by tools that only understand standard JSON. - $schema field
- A JSON property convention (not part of the JSON spec) that references a JSON Schema document describing the structure of the containing JSON file. Editors and tooling that implement the JSON Language Server Protocol read the
$schemavalue and fetch the referenced schema to enable IntelliSense — property autocompletion, hover documentation, type validation, and inline error highlighting. The value is a URI:"$schema": "https://json.schemastore.org/tsconfig"for tsconfig.json, or a relative local path"./my-schema.json"for custom schemas. Parsers and most JSON-consuming tools ignore the$schemafield entirely — it is advisory metadata for editor tooling only. SchemaStore (schemastore.org) hosts 500+ schemas for common config files. - tsconfig.json extends
- The
extendsfield in tsconfig.json specifies a base configuration file to inherit settings from. TypeScript performs a deep merge ofcompilerOptions— individual options from the extending file override matching options in the base, and non-conflicting options from both files are combined. The value can be a relative file path, an npm package path (e.g.,"@tsconfig/node20/tsconfig.json"), or an absolute path. TypeScript 5.0 added array support:"extends": ["@tsconfig/strictest", "./tsconfig.paths.json"], with later entries overriding earlier ones on conflicts. Critically,include,exclude, andfilesarrays are not merged — the extending file's arrays completely replace the base arrays. Install ready-made base configs withnpm install --save-dev @tsconfig/node20. - package.json exports
- The
"exports"field in package.json (introduced in Node.js 12) is the modern replacement for"main". It defines the public entry points for a package, supports conditional exports (different files for ESM vs CommonJS consumers via"import"and"require"conditions), and allows subpath exports (e.g.,"./utils": "./dist/utils.js"to enableimport { helper } from "my-package/utils"). When"exports"is present, it completely overrides"main"in Node.js 12+ and modern bundlers. Any file path not listed in"exports"is inaccessible to consumers — attempting to import it throwsERR_PACKAGE_PATH_NOT_EXPORTED, enforcing proper encapsulation of internal implementation files. - SchemaStore
- SchemaStore (
schemastore.org) is a community-maintained registry of JSON Schema definitions for popular configuration file formats. It hosts 500+ schemas covering tsconfig.json, package.json, .eslintrc.json, jest.config.json, .prettierrc.json, GitHub Actions workflows, Docker Compose files, and hundreds more. VS Code bundles a client that auto-detects and fetches schemas from SchemaStore for well-known filenames — providing IntelliSense without any user configuration. Schemas are referenced explicitly via the$schemafield pointing tohttps://json.schemastore.org/<schema-name>. The registry is open source (github.com/SchemaStore/schemastore) and accepts community contributions for new config formats.
FAQ
Why can't I add comments to JSON config files?
Standard JSON (RFC 8259) forbids comments entirely — the spec defines no comment syntax. This was a deliberate design decision: JSON was intended as a pure data interchange format, not a configuration language, and comments create ambiguity about whether they should affect parsing behavior. For config files that need inline documentation, use JSONC (// and /* */ comments, supported by tsconfig.json and VS Code configs) or JSON5 (full comment support plus trailing commas and unquoted keys, requires the json5 npm package). If your tool only accepts standard JSON, a common workaround is a "_comment" property: { "_comment": "This value controls X", "timeout": 5000 } — it pollutes the data structure but is parseable. The best solution is to migrate to a format that supports comments natively.
How do I add autocomplete to a JSON config file?
Add a "$schema" field at the top of the file pointing to a JSON Schema URL. VS Code and other LSP-aware editors read this field and enable IntelliSense — autocompletion, hover docs, and inline validation. For tsconfig.json: "$schema": "https://json.schemastore.org/tsconfig". For package.json: "$schema": "https://json.schemastore.org/package". SchemaStore hosts 500+ schemas for common config files at https://json.schemastore.org/<name>. VS Code auto-detects schemas for well-known filenames (tsconfig.json, package.json, .eslintrc.json) without the $schema field. For custom config formats your team creates, write a JSON Schema document, host it at a URL or save it locally, and reference it: "$schema": "./my-schema.json". You can also configure VS Code to apply a schema to a file pattern via .vscode/settings.json "json.schemas" without modifying the config files themselves.
What is the tsconfig.json "extends" field?
The extends field specifies a base tsconfig.json to inherit settings from — TypeScript deep-merges compilerOptions from the base into the current file, with the current file winning conflicts. The value is a path to a JSON file: a relative path ("./tsconfig.base.json"), an npm package ("@tsconfig/node20/tsconfig.json"), or absolute. Install community-maintained base configs: npm install --save-dev @tsconfig/node20 then "extends": "@tsconfig/node20/tsconfig.json". TypeScript 5.0+ accepts an array: "extends": ["@tsconfig/strictest", "./tsconfig.paths.json"]. Key gotcha: include, exclude, and files arrays are not merged — the extending file's arrays replace the base arrays entirely. Only compilerOptions is deeply merged. Always specify include and exclude explicitly in each project-level tsconfig rather than relying on base inheritance for file selection.
How do I use JSON5 in Node.js?
Install the json5 package: npm install json5. For ESM projects: import JSON5 from 'json5'; const config = JSON5.parse(fileContents) — JSON5.parse() accepts JSON5 syntax including comments, trailing commas, and unquoted keys, and returns a plain JavaScript object. For CommonJS projects, auto-register the .json5 extension so require() handles it: require('json5/lib/register'); then const config = require('./config.json5'). To serialize back: JSON5.stringify(obj, null, 2). Since JSON5 is a strict superset of JSON, all valid JSON parses correctly with JSON5.parse(). Common gotcha: json5 adds ~28KB to your dependency tree — acceptable for Node.js servers, avoid in browser bundles where JSON.parse() is free. Check that every tool in your pipeline (linters, bundlers, test runners) supports .json5 files before adopting the format.
What is the difference between "main" and "exports" in package.json?
"main" is the legacy single-file entry point — require('your-package') loads this file. It was designed for CommonJS and supports only one entry point. "exports" is the modern replacement (Node.js 12+) supporting multiple entry points, conditional exports (separate files for import vs require), and subpath exports ("./utils": "./dist/utils.js"). When "exports" is present, it completely overrides "main" in Node.js 12+ and modern bundlers — files not listed in "exports" are blocked from direct import. Use "exports" for all newly published packages: { ".": { "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" } }. Keep "main" as a fallback only for compatibility with tools that do not read "exports" (rare today). The "module" field (not an official Node.js field) is read by bundlers like Rollup and Webpack 4 for ESM — superseded by "exports"["."]["import"] in modern tooling.
How do I have different configs for development and production?
Use a base + environment override pattern: config/default.json (shared settings), config/development.json and config/production.json (environment-specific overrides). Load and deep-merge at startup: const env = process.env.NODE_ENV ?? 'development', read both files, merge with a recursive deep merge function (not shallow spread — that replaces nested objects entirely). For TypeScript configs, create tsconfig.json (development) and tsconfig.build.json (production, extends base, disables source maps, enables strict unused-variable checks). Run the correct config via npm scripts: "build": "tsc --project tsconfig.build.json". Keep secrets (API keys, database passwords) in environment variables, never in committed JSON files — inject them into the merged config object at runtime from process.env. For Next.js and Vite projects, use their built-in environment variable handling (.env.local, .env.production) instead of custom JSON config loading.
How do I validate a JSON config file?
Use Ajv (Another JSON Validator): npm install ajv ajv-formats. Define a JSON Schema for your config structure, compile it with const validate = ajv.compile(schema), and call validate(config) at application startup — if it returns false, read validate.errors for detailed error messages. Set allErrors: true in Ajv's constructor to collect all errors in one pass (not just the first). Use additionalProperties: false in your schema to reject typos in property names. For TypeScript, Ajv's JSONSchemaType<T> turns validate() into a type guard — after passing, TypeScript knows the validated data matches your interface. For CI validation, use ajv-cli: add "validate:config": "ajv validate -s schema.json -d config.json" to your package.json scripts and run it in your CI pipeline before deploying. Editor-time validation (without code) comes from adding a $schema field pointing to your schema.
What is JSONC?
JSONC (JSON with Comments) is a superset of standard JSON that adds // single-line comments and /* */ block comments. It is not an official RFC standard but is widely adopted in the VS Code and TypeScript ecosystems — tsconfig.json, .vscode/settings.json, .vscode/launch.json, .vscode/tasks.json, and jsconfig.json all use JSONC format. Tools that read JSONC strip comments before passing the content to a standard JSON parser, so the data structure is identical to JSON. Parse JSONC in Node.js with Microsoft's jsonc-parser package: import { parse } from 'jsonc-parser'; const config = parse(fileContents). Unlike JSON5, JSONC does not add trailing commas, unquoted keys, or single-quoted strings as official features — though VS Code's parser tolerates trailing commas in practice. Never use JSON.parse() to read tsconfig.json in tooling scripts — the comments cause a SyntaxError.
Further reading and primary sources
- TypeScript tsconfig.json Reference — Official reference for every tsconfig.json compilerOptions field with examples
- Node.js package.json exports field — Node.js documentation for the exports field, conditional exports, and subpath exports
- JSON5 Specification — Full JSON5 specification including all syntax extensions over standard JSON
- SchemaStore — Community registry of 500+ JSON schemas for common configuration file formats
- Ajv Documentation — Ajv JSON Schema validator — getting started, TypeScript integration, and error handling