diff --git a/eslint.config.mjs b/eslint.config.mjs index 77ee8b0..672ec41 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,17 +1,15 @@ -import js from '@eslint/js'; -import globals from 'globals'; +import eslint from '@eslint/js'; import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: ['**/.history/**', 'node_modules/**', 'dist/**'], }, - js.configs.recommended, - ...tseslint.configs.strictTypeChecked, - ...tseslint.configs.stylisticTypeChecked, + eslint.configs.recommended, + tseslint.configs.strictTypeChecked, + tseslint.configs.stylisticTypeChecked, { languageOptions: { - globals: globals.node, parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname, @@ -19,14 +17,129 @@ export default tseslint.config( }, }, { - files: ['**/*.ts'], + // These rules are defined as errors by "strictTypeChecked" and "stylisticTypeChecked", but do they really cause errors? No. This makes DX worse. + // Imagine you create a class you plan to finish later with only the constructor. You'll get the "no-extraneous-class" error and won't be able to transpile. + // If you're like me, you'll get annoyed by that error the whole time and won't be able to focus. + // So they should be categorized as warnings. + // The whole idea of this approach is to make sure you can see things that could really break your code and fix them as soon as possible, while letting warnings be warnings. rules: { - '@typescript-eslint/no-inferrable-types': 'off', // Explicit type annotations improve code readability and make intent clearer, especially in utility functions where type safety is paramount. - '@typescript-eslint/no-unused-vars': 'warn', + // ================================================================ + // Error Prevention - Catch bugs and potential runtime errors + // ================================================================ + 'no-shadow': 'error', + 'no-undef': 'error', + 'no-unreachable': 'warn', + 'no-var': 'warn', + 'prefer-const': 'warn', + '@typescript-eslint/await-thenable': 'warn', + '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-for-in-array': 'warn', + '@typescript-eslint/no-unnecessary-type-assertion': 'warn', + '@typescript-eslint/no-unused-vars': ['warn'], + '@typescript-eslint/no-use-before-define': 'warn', + '@typescript-eslint/require-await': 'warn', + '@typescript-eslint/strict-boolean-expressions': 'error', + + // ================================================================ + // Code Quality - Maintainability, readability, best practices + // ================================================================ + 'no-alert': 'warn', + 'no-else-return': 'warn', + 'no-empty-pattern': 'warn', + 'no-empty': 'warn', + 'no-useless-catch': 'warn', + 'vars-on-top': 'warn', + 'max-classes-per-file': 'warn', + 'no-warning-comments': 'warn', // Maintain visibility over FIXMEs and TODOs comments + '@typescript-eslint/no-extraneous-class': 'warn', + '@typescript-eslint/no-inferrable-types': 'off', // Explicit type annotations improve readability and make intent clearer, especially in utility functions where type safety is paramount. + '@typescript-eslint/no-unnecessary-type-parameters': 'warn', + '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'warn', + '@typescript-eslint/no-unnecessary-type-conversion': 'warn', + '@typescript-eslint/no-unnecessary-template-expression': 'warn', + '@typescript-eslint/no-empty-function': 'warn', + '@typescript-eslint/promise-function-async': 'warn', + '@typescript-eslint/no-empty-object-type': 'warn', + '@typescript-eslint/no-empty-interface': 'warn', + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-confusing-void-expression': [ + 'warn', + { + ignoreArrowShorthand: true, + ignoreVoidOperator: false, + }, + ], + '@typescript-eslint/no-unnecessary-condition': 'warn', + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/explicit-module-boundary-types': 'warn', + '@typescript-eslint/typedef': [ + 'warn', + { + arrayDestructuring: true, + objectDestructuring: true, + arrowParameter: true, + memberVariableDeclaration: true, + parameter: true, + propertyDeclaration: true, + variableDeclaration: true, + variableDeclarationIgnoreFunction: true, + }, + ], + + // ================================================================ + // Code Style - Formatting and stylistic consistency + // ================================================================ + 'padding-line-between-statements': [ + 'warn', + { blankLine: 'always', prev: ['const', 'let'], next: '*' }, + { blankLine: 'any', prev: ['const', 'let'], next: ['const', 'let'] }, + { blankLine: 'always', prev: '*', next: 'return' }, + ], + 'no-continue': 'warn', + semi: 'warn', + '@typescript-eslint/consistent-indexed-object-style': 'warn', + '@typescript-eslint/consistent-type-definitions': 'warn', + '@typescript-eslint/consistent-type-exports': 'warn', + '@typescript-eslint/consistent-type-imports': 'warn', + + // ================================================================ + // Performance - Performance-related rules + // ================================================================ + 'prefer-destructuring': 'warn', // Can improve performance by avoiding repeated property access + 'prefer-template': 'warn', // Template literals are generally faster than string concatenation + '@typescript-eslint/prefer-nullish-coalescing': 'warn', // More efficient than || for null/undefined checks + '@typescript-eslint/dot-notation': 'warn', // Bracket notation can be slower than dot notation + + // ================================================================ + // Security - Security-related rules + // ================================================================ + 'no-param-reassign': 'warn', + 'no-debugger': 'warn', // Debug statements can make the expose of sensitive information easier to see, which is not good in production + 'no-console': 'warn', // Console statements should not show up in production + eqeqeq: 'warn', // Prevents type coercion vulnerabilities + '@typescript-eslint/no-unsafe-call': 'warn', + '@typescript-eslint/no-unsafe-argument': 'warn', + '@typescript-eslint/no-unsafe-assignment': 'warn', + '@typescript-eslint/no-unsafe-member-access': 'warn', + '@typescript-eslint/no-unsafe-return': 'warn', + '@typescript-eslint/no-implied-eval': 'warn', // Prevents code injection }, }, { + // Disable type checking for non-ts files + // https://typescript-eslint.io/users/configs/#disable-type-checked files: ['**/*.mjs'], - ...tseslint.configs.disableTypeChecked, + extends: [tseslint.configs.disableTypeChecked], + rules: { + 'no-console': 'off', + }, + }, + { + files: ['**/*.test.ts'], + extends: [tseslint.configs.disableTypeChecked], + rules: { + // We don't need to be that strict with tests + '@typescript-eslint/typedef': 'off', + }, }, ); diff --git a/package.json b/package.json index 9d613f7..ada986a 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,9 @@ "version": "1.0.0-alpha.1", "description": "Generic utility functions for JavaScript", "license": "ISC", - "main": "./dist/index.cjs", - "module": "./dist/index.js", - "types": "./dist/index.d.ts", "exports": { ".": { - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs", "types": "./dist/index.d.ts" } @@ -17,9 +14,9 @@ "scripts": { "build": "rollup -c", "build:watch": "rollup -c -w", - "check-types": "tsc --pretty --noEmit --project tsconfig.json", - "lint": "eslint '{src,tests}/**/*.ts'", - "lint:fix": "eslint '{src,tests}/**/*.ts' --fix", + "check-types": "tsc --pretty --project tsconfig.json", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier --write .", "test": "jest", "test:watch": "jest --watch", @@ -55,7 +52,7 @@ "dist" ], "engines": { - "node": "22.x", + "node": ">=18", "pnpm": "10.x" }, "packageManager": "pnpm@10.15.0" diff --git a/rollup.config.mjs b/rollup.config.mjs index 4a7df4a..5c039c2 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -8,7 +8,7 @@ const config = [ input: 'src/index.ts', output: [ { - file: 'dist/index.js', + file: 'dist/index.mjs', format: 'es', sourcemap: true, }, @@ -22,10 +22,6 @@ const config = [ nodeResolve(), commonjs(), typescript({ - tsconfig: './tsconfig.json', - declaration: true, - declarationDir: './dist', - outDir: './dist', exclude: ['tests/**/*'], }), ], @@ -34,14 +30,7 @@ const config = [ { input: 'src/index.ts', output: [{ file: 'dist/index.d.ts', format: 'es' }], - plugins: [ - dts({ - compilerOptions: { - baseUrl: '.', - paths: { '@/*': ['./src/*'] }, - }, - }), - ], + plugins: [dts()], }, ]; diff --git a/src/isEmpty.ts b/src/isEmpty.ts index 32f23fc..66e4c81 100644 --- a/src/isEmpty.ts +++ b/src/isEmpty.ts @@ -1,47 +1,102 @@ +import { getTag } from './_getTag'; +import { isPlainObject } from './isPlainObject'; + /** * Checks if the given value is empty. * - * 🚨 The values listed below are not considered empty (they are falsy, not empty): + * ## What is considered empty: + * - `null` and `undefined` + * - Empty strings (`""`) and whitespace-only strings (`" "`) + * - `NaN` (Not a Number) + * - Empty arrays (`[]`) + * - Empty Map and Set objects + * - Objects with no enumerable properties (`{}`) + * + * ## Design Philosophy: Empty vs Falsy + * + * This function is specifically designed to check for **emptiness**, not **falsiness**. + * While JavaScript's falsy values (`false`, `0`, `""`, `null`, `undefined`, `NaN`, `0n`) + * all evaluate to `false` in boolean contexts, they serve different semantic purposes: + * + * - **Falsy values** represent "false-like" states in boolean logic + * - **Empty values** represent "absence of content" or "no data" + * + * ### Why the distinction matters: + * + * 1. **`false`** is a valid boolean state, not empty data + * 2. **`0`** is a valid number representing zero, not absence of a number + * 3. **`0n`** is a valid BigInt representing zero, not absence of a BigInt + * 4. **`""`** represents no textual content - this IS empty + * 5. **`null`/`undefined`** represent absence of value - these ARE empty + * 6. **`NaN`** represents an invalid/failed computation - this IS empty + * + * ### Use cases: + * - **Form validation**: `isEmpty("")` → true, `isEmpty(false)` → false + * - **Data processing**: `isEmpty([])` → true, `isEmpty(0)` → false + * - **API responses**: `isEmpty({})` → true, `isEmpty({count: 0})` → false + * + * ## What is NOT considered empty (valid data, even if falsy): * - The boolean `false` - * - The number zero `0` + * - The number zero `0`, `-0`, `Infinity`, `-Infinity` + * - The BigInt zero `BigInt(0)` or `0n` + * - Symbols (all symbols are considered valid data) + * + * ## Special considerations: + * - Objects are considered empty if they have no enumerable properties + * - Objects with only symbol-keyed or non-enumerable properties are considered empty + * - WeakMap and WeakSet are not supported because there is no way to check if they are empty and this is a limitation created by design of these objects * * @param value - The value to check. * @returns `true` if the value is empty, `false` otherwise. */ export function isEmpty(value: unknown): boolean { - if (typeof value === 'boolean') { - return false; - } - if (value === undefined || value === null) { return true; } - if (typeof value === 'string') { + if (typeof value === 'boolean' || value instanceof Boolean) { + return false; + } + + if (typeof value === 'string' || value instanceof String) { return value.trim() === ''; } - if (typeof value === 'number') { - /** - * `isNaN` checks whether the value is not a number or cannot be converted - * into a number. `Number.isNaN` only checks if the value is equal to NaN. - */ - return isNaN(value); + if (typeof value === 'number' || value instanceof Number) { + // Only NaN is treated as empty; all other numbers, + // including 0, -0, ±Infinity are not empty. + return Number.isNaN(value.valueOf()); } - if (Array.isArray(value)) { - return value.length === 0; + if (typeof value === 'bigint' || value instanceof BigInt) { + return false; } if (value instanceof Map || value instanceof Set) { return value.size === 0; } - if (typeof value === 'object') { + if (Array.isArray(value)) { + return value.length === 0; + } + + const tag = getTag(value); + + if (/^\[object (?:[A-Z]\w*Array)\]$/.test(tag)) { + return (value as ArrayLike).length === 0; + } + + if (isPlainObject(value)) { return Object.keys(value).length === 0; } - throw new Error( - `The given argument could not be parsed: ${JSON.stringify(value)}`, - ); + if (typeof value === 'symbol' || value instanceof Symbol) { + return false; + } + + if (typeof value === 'function') { + return false; + } + + throw new TypeError(`The given argument is not supported: ${tag}`); } diff --git a/src/isPlainObject.ts b/src/isPlainObject.ts index f9093ca..1856d6d 100644 --- a/src/isPlainObject.ts +++ b/src/isPlainObject.ts @@ -11,6 +11,7 @@ export function isPlainObject( typeof value === 'object' && value !== null && !Array.isArray(value) && - Object.getPrototypeOf(value) === Object.prototype + (Object.getPrototypeOf(value) === Object.prototype || + Object.getPrototypeOf(value) === null) ); } diff --git a/tests/isEmpty.test.ts b/tests/isEmpty.test.ts new file mode 100644 index 0000000..feb9622 --- /dev/null +++ b/tests/isEmpty.test.ts @@ -0,0 +1,176 @@ +/* eslint-disable @typescript-eslint/no-array-constructor */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { isEmpty } from '@/isEmpty'; +import { describe, expect, it } from '@jest/globals'; + +describe('isEmpty', () => { + describe('boolean values', () => { + it('should return false for boolean values', () => { + expect(isEmpty(true)).toBe(false); + expect(isEmpty(false)).toBe(false); + expect(isEmpty(new Boolean(true))).toBe(false); + expect(isEmpty(new Boolean(false))).toBe(false); + }); + }); + + describe('null and undefined', () => { + it('should return true for null and undefined', () => { + expect(isEmpty(null)).toBe(true); + expect(isEmpty(undefined)).toBe(true); + }); + }); + + describe('strings', () => { + it('should return true for empty strings', () => { + expect(isEmpty('')).toBe(true); + expect(isEmpty(' ')).toBe(true); + expect(isEmpty('\n\t')).toBe(true); + expect(isEmpty('\u00A0\u2007\u202F\u3000')).toBe(true); // Unicode spaces + expect(isEmpty(new String(''))).toBe(true); + }); + + it('should return false for non-empty strings', () => { + expect(isEmpty('hello')).toBe(false); + expect(isEmpty(' hello ')).toBe(false); + expect(isEmpty('0')).toBe(false); + expect(isEmpty(new String('Hi'))).toBe(false); + }); + }); + + describe('numeric values', () => { + it('should return true for NaN', () => { + expect(isEmpty(NaN)).toBe(true); + expect(isEmpty(Number.NaN)).toBe(true); + expect(isEmpty(new Number(NaN))).toBe(true); + }); + + it('should return false for valid numbers including zero', () => { + expect(isEmpty(0)).toBe(false); + expect(isEmpty(-0)).toBe(false); + expect(isEmpty(1)).toBe(false); + expect(isEmpty(-1)).toBe(false); + expect(isEmpty(3.14)).toBe(false); + expect(isEmpty(new Number(42))).toBe(false); + }); + + it('should return false for BigInt values', () => { + expect(isEmpty(BigInt(0))).toBe(false); + expect(isEmpty(BigInt(42))).toBe(false); + // BigInt literals are not available when targeting lower than ES2020. + // expect(isEmpty(Object(0n))).toBe(false); + }); + + it('should return false for Infinity values', () => { + expect(isEmpty(Infinity)).toBe(false); + expect(isEmpty(-Infinity)).toBe(false); + expect(isEmpty(Number.POSITIVE_INFINITY)).toBe(false); + expect(isEmpty(Number.NEGATIVE_INFINITY)).toBe(false); + }); + }); + + describe('arrays', () => { + it('should return true for regular empty arrays', () => { + expect(isEmpty([])).toBe(true); + expect(isEmpty(new Array())).toBe(true); + }); + + it('should return false for regular non-empty arrays', () => { + expect(isEmpty([1, 2, 3])).toBe(false); + expect(isEmpty([''])).toBe(false); + expect(isEmpty([null])).toBe(false); + }); + + it.each([ + ['Uint8Array', Uint8Array], + ['Uint8Array', Uint8Array], + ['Int8Array', Int8Array], + ['Int8Array', Int8Array], + ['Uint16Array', Uint16Array], + ['Uint16Array', Uint16Array], + ['Int16Array', Int16Array], + ['Int16Array', Int16Array], + ['Uint32Array', Uint32Array], + ['Uint32Array', Uint32Array], + ['Int32Array', Int32Array], + ['Int32Array', Int32Array], + ['Float32Array', Float32Array], + ['Float32Array', Float32Array], + ['Float64Array', Float64Array], + ['Float64Array', Float64Array], + ['BigInt64Array', BigInt64Array], + ['BigInt64Array', BigInt64Array], + ['BigUint64Array', BigUint64Array], + ['BigUint64Array', BigUint64Array], + ])('should check typed array: %s', (_, TypedObject) => { + expect(isEmpty(new TypedObject())).toBe(true); + expect(isEmpty(new TypedObject(2))).toBe(false); + }); + }); + + describe('Map and Set', () => { + it('should return true for empty Map and Set', () => { + expect(isEmpty(new Map())).toBe(true); + expect(isEmpty(new Set())).toBe(true); + }); + + it('should return false for non-empty Map and Set', () => { + expect(isEmpty(new Map([['key', 'value']]))).toBe(false); + expect(isEmpty(new Set([1, 2, 3]))).toBe(false); + }); + }); + + describe('objects', () => { + it('should return true for empty objects', () => { + expect(isEmpty({})).toBe(true); + }); + + it('should return true for objects with null prototype', () => { + expect(isEmpty(Object.create(null))).toBe(true); + }); + + it('should return false for non-empty objects', () => { + expect(isEmpty({ key: 'value' })).toBe(false); + expect(isEmpty({ empty: '' })).toBe(false); + }); + + it('should return true for objects with only non-enumerable properties', () => { + const o = {}; + + Object.defineProperty(o, 'hidden', { value: 1, enumerable: false }); + expect(isEmpty(o)).toBe(true); + }); + + it('should return true for objects with only symbol keys', () => { + const s = Symbol('k'); + const o = { [s]: 1 }; + + expect(isEmpty(o)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('should return false for function values', () => { + expect(isEmpty(() => {})).toBe(false); + }); + + // You cannot directly check if a WeakMap or WeakSet is empty! This + // is a fundamental limitation of WeakMaps and WeakSets due to their design. + it('should throw error for WeakMap/WeakSet', () => { + expect(() => isEmpty(new WeakMap())).toThrow(TypeError); + expect(() => isEmpty(new WeakSet())).toThrow(TypeError); + }); + + it('should return false for Symbol values', () => { + expect(isEmpty(Symbol())).toBe(false); + expect(isEmpty(Object(Symbol()))).toBe(false); + expect(isEmpty(Symbol('test'))).toBe(false); + expect(isEmpty(Symbol.for('global'))).toBe(false); + expect(isEmpty(Symbol.iterator)).toBe(false); + expect(isEmpty(Symbol.toStringTag)).toBe(false); + }); + + it.todo( + 'Decide: if non-primitive built-ins should be included: Date, Error, RegExp, Promise...', + ); + }); +}); diff --git a/tests/test.test.ts b/tests/test.test.ts deleted file mode 100644 index c3b2d1b..0000000 --- a/tests/test.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -describe('test', () => { - it('should be true', () => { - expect(true).toBe(true); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 81072ad..9dcd6fa 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,36 +1,44 @@ { "extends": "@tsconfig/node22/tsconfig.json", "compilerOptions": { - "allowJs": true, - "allowUnreachableCode": false, - "alwaysStrict": true, + // ============================================ + // Environment + // ============================================ "baseUrl": ".", - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "isolatedModules": true, - "lib": ["es2024", "ESNext.Array", "ESNext.Collection", "ESNext.Iterator"], + "paths": { "@/*": ["./src/*"] }, + "outDir": "./dist", + "noEmit": true, "module": "ESNext", + "moduleResolution": "Bundler", "moduleDetection": "force", - "moduleResolution": "node", - "noEmit": false, + "isolatedModules": true, + // ============================================ + // Type Safety + // ============================================ + "allowUnreachableCode": false, + "alwaysStrict": true, "noFallthroughCasesInSwitch": true, "noImplicitAny": true, "noImplicitOverride": true, "noImplicitReturns": true, "noImplicitThis": true, "noUncheckedIndexedAccess": true, - "noUnusedLocals": false, // => ESLint - "noUnusedParameters": false, // => ESLint - "paths": { "@/*": ["./src/*"] }, - "preserveConstEnums": true, - "removeComments": true, - "resolveJsonModule": true, "strictNullChecks": true, - "target": "ES2017", "useUnknownInCatchVariables": true, - "verbatimModuleSyntax": true + // "noUnusedLocals": false, // => ESLint + // "noUnusedParameters": false, // => ESLint + // ============================================ + // Interoperability + // ============================================ + "resolveJsonModule": true, + // ============================================ + // Build & Performance + // ============================================ + "removeComments": true, + "verbatimModuleSyntax": true, + "preserveConstEnums": true, + "forceConsistentCasingInFileNames": true }, "include": ["src/**/*.ts", "tests/**/*.ts"], - "exclude": ["node_modules", ".history"] + "exclude": ["node_modules", ".history", "dist"] }