diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2996cf5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Notes + +## Branded types for type guards + +TypeScript type guards narrow in both branches. If `is.integer(n)` returns `value is number` and the input is `number`, the false branch computes `Exclude` = `never`. This makes common patterns like `if (!is.integer(n)) throw; use(n)` fail because `n` becomes `never` after the guard. + +To avoid this, type guard predicates use branded types (e.g., `number & {readonly __brand: 'Integer'}`, `string & {readonly __brand: 'UrlString'}`). A branded subtype ensures the false branch stays the original type (e.g., `Exclude` = `number`). + +Assert functions (`asserts value is T`) don't need branded types since they throw on failure and have no false branch. They use plain types like `asserts value is number`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/package.json b/package.json index b5c7463..0222a80 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ }, "scripts": { "build": "del distribution && tsc", - "test": "tsc --noEmit && xo && node --experimental-transform-types --test test/test.ts", + "test": "tsc --noEmit && tsc --project test/tsconfig.json --noEmit && xo && node --experimental-transform-types --test test/test.ts", "prepare": "npm run build" }, "files": [ diff --git a/source/index.ts b/source/index.ts index 6104501..eee1316 100644 --- a/source/index.ts +++ b/source/index.ts @@ -1,14 +1,29 @@ import type { ArrayLike, Class, + EvenInteger, Falsy, + FiniteNumber, + Integer, + NaN as NaNType, + NegativeInfinity, + NegativeInteger, + NegativeNumber, NodeStream, NonEmptyString, + NonNegativeInteger, + NonNegativeNumber, ObservableLike, + OddInteger, Predicate, Primitive, + PositiveInfinity, + PositiveInteger, + PositiveNumber, + SafeInteger, TypedArray, UrlString, + ValidLength, WeakRef, Whitespace, } from './types.ts'; @@ -22,6 +37,15 @@ type ExtractFromGlobalConstructors = type NodeBuffer = ExtractFromGlobalConstructors<'Buffer'>; +type NumericGuardResult = + ( + unknown extends Input + ? Branded + : Input extends number + ? Branded & Input + : number + ) & Input; + const typedArrayTypeNames = [ 'Int8Array', 'Uint8Array', @@ -99,6 +123,7 @@ function isPrimitiveTypeName(name: unknown): name is PrimitiveTypeName { export type TypeName = ObjectTypeName | PrimitiveTypeName; const assertionTypeDescriptions = [ + 'bound Function', 'positive number', 'negative number', 'Class', @@ -139,6 +164,7 @@ const assertionTypeDescriptions = [ 'non-negative number', 'odd integer', 'positive integer', + 'safe integer', 'T', 'in range', 'predicate returns truthy for any value', @@ -225,8 +251,7 @@ function detect(value: unknown): TypeName { return 'Promise'; } - const objectTag = Object.prototype.toString.call(value).slice(8, -1); - if (objectTag === 'String' || objectTag === 'Boolean' || objectTag === 'Number') { + if (isBoxedPrimitiveObject(value)) { throw new TypeError('Please don\'t use object wrappers for primitive types'); } @@ -237,6 +262,23 @@ function hasPromiseApi(value: unknown): value is Promise { return isFunction((value as Promise)?.then) && isFunction((value as Promise)?.catch); } +function hasBoxedPrimitiveBrand(value: unknown, valueOf: () => unknown): boolean { + try { + // `Object.prototype.toString` can be spoofed via `Symbol.toStringTag`, but the + // boxed primitive `valueOf` methods still enforce the real internal brand. + Reflect.apply(valueOf, value, []); + return true; + } catch { + return false; + } +} + +function isBoxedPrimitiveObject(value: unknown): boolean { + return hasBoxedPrimitiveBrand(value, String.prototype.valueOf) + || hasBoxedPrimitiveBrand(value, Boolean.prototype.valueOf) + || hasBoxedPrimitiveBrand(value, Number.prototype.valueOf); +} + const is = Object.assign( detect, { @@ -560,11 +602,13 @@ export function isEnumCase(value: unknown, targetEnum: T): value is } export function isError(value: unknown): value is Error { - // TODO: Use `Error.isError` when targeting Node.js 24.` + // TODO: Use `Error.isError` when targeting Node.js 24. return getObjectType(value) === 'Error'; } -export function isEvenInteger(value: unknown): value is number { +// For numeric guards, preserve branded narrowing for `unknown`, keep the false branch usable for plain `number`, and still narrow mixed unions to `number`. +export function isEvenInteger(value: Input): value is NumericGuardResult; +export function isEvenInteger(value: unknown): boolean { return isAbsoluteModule2(0)(value); } @@ -573,7 +617,8 @@ export function isFalsy(value: unknown): value is Falsy { return !value; } -export function isFiniteNumber(value: unknown): value is number { +export function isFiniteNumber(value: Input): value is NumericGuardResult; +export function isFiniteNumber(value: unknown): boolean { return Number.isFinite(value); } @@ -622,7 +667,8 @@ export function isHtmlElement(value: unknown): value is HTMLElement { && DOM_PROPERTIES_TO_CHECK.every(property => property in value); } -export function isInfinite(value: unknown): value is number { +export function isInfinite(value: Input): value is NumericGuardResult; +export function isInfinite(value: unknown): boolean { return value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY; } @@ -654,7 +700,8 @@ export function isInt8Array(value: unknown): value is Int8Array { return getObjectType(value) === 'Int8Array'; } -export function isInteger(value: unknown): value is number { +export function isInteger(value: Input): value is NumericGuardResult; +export function isInteger(value: unknown): boolean { return Number.isInteger(value); } @@ -666,7 +713,8 @@ export function isMap(value: unknown): value is return getObjectType(value) === 'Map'; } -export function isNan(value: unknown) { +export function isNan(value: Input): value is NumericGuardResult; +export function isNan(value: unknown): boolean { return Number.isNaN(value); } @@ -674,11 +722,13 @@ export function isNativePromise(value: unknown): value is Promise(value: Input): value is NumericGuardResult; +export function isNegativeInteger(value: unknown): boolean { return isInteger(value) && value < 0; } -export function isNegativeNumber(value: unknown): value is number { +export function isNegativeNumber(value: Input): value is NumericGuardResult; +export function isNegativeNumber(value: unknown): boolean { return isNumber(value) && value < 0; } @@ -714,11 +764,13 @@ export function isNonEmptyStringAndNotWhitespace(value: unknown): value is NonEm return isString(value) && !isEmptyStringOrWhitespace(value); } -export function isNonNegativeInteger(value: unknown): value is number { +export function isNonNegativeInteger(value: Input): value is NumericGuardResult; +export function isNonNegativeInteger(value: unknown): boolean { return isInteger(value) && value >= 0; } -export function isNonNegativeNumber(value: unknown): value is number { +export function isNonNegativeNumber(value: Input): value is NumericGuardResult; +export function isNonNegativeNumber(value: unknown): boolean { return isNumber(value) && value >= 0; } @@ -763,7 +815,8 @@ export function isObservable(value: unknown): value is ObservableLike { return false; } -export function isOddInteger(value: unknown): value is number { +export function isOddInteger(value: Input): value is NumericGuardResult; +export function isOddInteger(value: unknown): boolean { return isAbsoluteModule2(1)(value); } @@ -783,11 +836,13 @@ export function isPlainObject(value: unknown): value is Record< return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value); } -export function isPositiveInteger(value: unknown): value is number { +export function isPositiveInteger(value: Input): value is NumericGuardResult; +export function isPositiveInteger(value: unknown): boolean { return isInteger(value) && value > 0; } -export function isPositiveNumber(value: unknown): value is number { +export function isPositiveNumber(value: Input): value is NumericGuardResult; +export function isPositiveNumber(value: unknown): boolean { return isNumber(value) && value > 0; } @@ -808,7 +863,8 @@ export function isRegExp(value: unknown): value is RegExp { return getObjectType(value) === 'RegExp'; } -export function isSafeInteger(value: unknown): value is number { +export function isSafeInteger(value: Input): value is NumericGuardResult; +export function isSafeInteger(value: unknown): boolean { return Number.isSafeInteger(value); } @@ -900,7 +956,8 @@ export function isValidDate(value: unknown): value is Date { return isDate(value) && !isNan(Number(value)); } -export function isValidLength(value: unknown): value is number { +export function isValidLength(value: Input): value is NumericGuardResult; +export function isValidLength(value: unknown): boolean { return isSafeInteger(value) && value >= 0; } @@ -956,6 +1013,8 @@ function typeErrorMessageMultipleValues(expectedType: AssertionTypeDescription | } // Type assertions have to be declared with an explicit type. +// Keep assertion outputs unbranded even when the corresponding `is.*` guard uses a branded subtype. +// The brands exist to preserve useful false-branch narrowing for type guards on `number` inputs, which does not apply to `asserts`. type Assert = { // Unknowns. undefined: (value: unknown, message?: string) => asserts value is undefined; @@ -1188,7 +1247,7 @@ const methodTypeMap = { isBigUint64Array: 'BigUint64Array', isBlob: 'Blob', isBoolean: 'boolean', - isBoundFunction: 'Function', + isBoundFunction: 'bound Function', isBuffer: 'Buffer', isClass: 'Class', isDataView: 'DataView', @@ -1247,7 +1306,7 @@ const methodTypeMap = { isPromise: 'Promise', isPropertyKey: 'PropertyKey', isRegExp: 'RegExp', - isSafeInteger: 'integer', + isSafeInteger: 'safe integer', isSet: 'Set', isSharedArrayBuffer: 'SharedArrayBuffer', isString: 'string', @@ -1391,7 +1450,7 @@ export function assertBoolean(value: unknown, message?: string): asserts value i // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type export function assertBoundFunction(value: unknown, message?: string): asserts value is Function { if (!isBoundFunction(value)) { - throw new TypeError(message ?? typeErrorMessage('Function', value)); + throw new TypeError(message ?? typeErrorMessage('bound Function', value)); } } @@ -1752,7 +1811,7 @@ export function assertRegExp(value: unknown, message?: string): asserts value is export function assertSafeInteger(value: unknown, message?: string): asserts value is number { if (!isSafeInteger(value)) { - throw new TypeError(message ?? typeErrorMessage('integer', value)); + throw new TypeError(message ?? typeErrorMessage('safe integer', value)); } } @@ -1891,10 +1950,25 @@ export default is; export type { ArrayLike, Class, + EvenInteger, + FiniteNumber, + Integer, + NaN, + NegativeInfinity, + NegativeInteger, + NegativeNumber, NodeStream, + NonNegativeInteger, + NonNegativeNumber, ObservableLike, + OddInteger, + PositiveInfinity, + PositiveInteger, + PositiveNumber, Predicate, Primitive, + SafeInteger, TypedArray, UrlString, + ValidLength, } from './types.ts'; diff --git a/source/types.ts b/source/types.ts index c37e927..9255072 100644 --- a/source/types.ts +++ b/source/types.ts @@ -78,9 +78,122 @@ export type NonEmptyString = string & {0: string}; export type Whitespace = ' '; +type Brand = Readonly>; + /** A string that represents a valid URL. This is a branded type to prevent incorrect TypeScript type narrowing. */ export type UrlString = string & {readonly __brand: 'UrlString'}; + +// Keep numeric guards branded and simple. This intentionally favors correct false-branch narrowing for `number` inputs over perfect success-branch narrowing for numeric literal unions. + +/** +The IEEE 754 "Not-a-Number" value, typed as a subtype of `number`. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NaN = number & Brand<'__nanBrand'>; + +/** +A finite number (excludes `NaN`, `Infinity`, and `-Infinity`). + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type FiniteNumber = number & Brand<'__finiteNumberBrand'>; + +/** +A number greater than or equal to zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NonNegativeNumber = number & Brand<'__nonNegativeNumberBrand'>; + +/** +An integer value (no fractional part). + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type Integer = FiniteNumber & Brand<'__integerBrand'>; + +/** +A number greater than zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type PositiveNumber = NonNegativeNumber & Brand<'__positiveNumberBrand'>; + +/** +A number less than zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NegativeNumber = number & Brand<'__negativeNumberBrand'>; + +/** +An integer less than zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NegativeInteger = Integer & NegativeNumber & Brand<'__negativeIntegerBrand'>; + +/** +An integer greater than or equal to zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NonNegativeInteger = Integer & NonNegativeNumber & Brand<'__nonNegativeIntegerBrand'>; + +/** +An integer greater than zero. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type PositiveInteger = NonNegativeInteger & PositiveNumber & Brand<'__positiveIntegerBrand'>; + +// Note: type-fest uses the `1e999` overflow trick to represent these types (since TypeScript has +// no built-in Infinity type), but we use branded types here for consistency and to avoid +// relying on numeric overflow behavior. + +/** +A positive infinite number (`Infinity`). + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type PositiveInfinity = PositiveNumber & Brand<'__positiveInfinityBrand'>; + +/** +A negative infinite number (`-Infinity`). + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type NegativeInfinity = NegativeNumber & Brand<'__negativeInfinityBrand'>; + +/** +A safe integer (within the range of `Number.MIN_SAFE_INTEGER` to `Number.MAX_SAFE_INTEGER`). + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type SafeInteger = Integer & Brand<'__safeIntegerBrand'>; + +/** +An even integer. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type EvenInteger = Integer & Brand<'__evenIntegerBrand'>; + +/** +An odd integer. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type OddInteger = Integer & Brand<'__oddIntegerBrand'>; + +/** +A non-negative safe integer, suitable as an array or string length. + +Branded to prevent false-branch narrowing to `never` when the input is `number`. +*/ +export type ValidLength = SafeInteger & NonNegativeInteger & Brand<'__validLengthBrand'>; diff --git a/test/test.ts b/test/test.ts index ce26b50..8e64d69 100644 --- a/test/test.ts +++ b/test/test.ts @@ -15,6 +15,7 @@ import is, { assert as isAssert, assertPropertyKey, type AssertionTypeDescription, + type NaN as NaNType, type Predicate, type Primitive, type TypedArray, @@ -156,7 +157,7 @@ const primitiveTypes = { safeInteger: { fixtures: [...reusableFixtures.integer, ...reusableFixtures.safeInteger], typename: 'number', - typeDescription: 'integer', + typeDescription: 'safe integer', }, infinite: { fixtures: [...reusableFixtures.infinite], @@ -208,6 +209,7 @@ const objectTypes = { object: { fixtures: [ Object.create({x: 1}), + {[Symbol.toStringTag]: 'String'}, ...reusableFixtures.plainObject, ], typename: 'Object', @@ -280,6 +282,7 @@ const objectTypes = { boundFunction: { fixtures: [...reusableFixtures.boundFunction, ...reusableFixtures.asyncFunction], typename: 'Function', + typeDescription: 'bound Function', }, map: { fixtures: [ @@ -526,6 +529,7 @@ test('is.positiveNumber', () => { assert.strictEqual(is.positiveNumber(-6), false); assert.strictEqual(is.positiveNumber(-1.4), false); assert.strictEqual(is.positiveNumber(Number.NEGATIVE_INFINITY), false); + assert.strictEqual(is.positiveNumber(Number.NaN), false); assert.throws(() => { isAssert.positiveNumber(0); @@ -544,6 +548,33 @@ test('is.positiveNumber', () => { }); }); +test('is.nan', () => { + assert.ok(is.nan(Number.NaN)); + assert.ok(is.nan(NaN)); // eslint-disable-line unicorn/prefer-number-properties + + assert.doesNotThrow(() => { + isAssert.nan(Number.NaN); + }); + + assert.strictEqual(is.nan(0), false); + assert.strictEqual(is.nan(-0), false); + assert.strictEqual(is.nan(1), false); + assert.strictEqual(is.nan(Number.POSITIVE_INFINITY), false); + assert.strictEqual(is.nan(Number.NEGATIVE_INFINITY), false); + assert.strictEqual(is.nan('NaN'), false); + assert.strictEqual(is.nan(undefined), false); + + assert.throws(() => { + isAssert.nan(0); + }); + assert.throws(() => { + isAssert.nan(1); + }); + assert.throws(() => { + isAssert.nan('NaN'); + }); +}); + test('is.finiteNumber', () => { assert.ok(is.finiteNumber(6)); assert.ok(is.finiteNumber(-6)); @@ -592,6 +623,7 @@ test('is.negativeNumber', () => { assert.strictEqual(is.negativeNumber(6), false); assert.strictEqual(is.negativeNumber(1.4), false); assert.strictEqual(is.negativeNumber(Number.POSITIVE_INFINITY), false); + assert.strictEqual(is.negativeNumber(Number.NaN), false); assert.throws(() => { isAssert.negativeNumber(0); @@ -729,6 +761,32 @@ test('is.nonNegativeInteger', () => { }); }); +test('is.infinite', () => { + assert.ok(is.infinite(Number.POSITIVE_INFINITY)); + assert.ok(is.infinite(Number.NEGATIVE_INFINITY)); + + assert.doesNotThrow(() => { + isAssert.infinite(Number.POSITIVE_INFINITY); + }); + assert.doesNotThrow(() => { + isAssert.infinite(Number.NEGATIVE_INFINITY); + }); + + assert.strictEqual(is.infinite(0), false); + assert.strictEqual(is.infinite(1), false); + assert.strictEqual(is.infinite(-1), false); + assert.strictEqual(is.infinite(Number.NaN), false); + assert.strictEqual(is.infinite(Number.MAX_VALUE), false); + assert.strictEqual(is.infinite('Infinity'), false); + + assert.throws(() => { + isAssert.infinite(0); + }); + assert.throws(() => { + isAssert.infinite(Number.NaN); + }); +}); + test('is.numericString supplemental', () => { assert.strictEqual(is.numericString(''), false); assert.strictEqual(is.numericString(' '), false); @@ -998,6 +1056,20 @@ test('is.urlString', () => { } })(); +// Type test for is.nan branded-type narrowing +(() => { + const value: unknown = Number.NaN; + + if (is.nan(value)) { + // ✅ In true branch: value is narrowed to the branded NaN type + expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(value).toMatchTypeOf(); + } else { + // ✅ In false branch: value remains unknown (not incorrectly narrowed) + expectTypeOf(value).toEqualTypeOf(); + } +})(); + test('is.truthy', () => { assert.ok(is.truthy('unicorn')); assert.ok(is.truthy('🦄')); @@ -2268,370 +2340,379 @@ test('assert', () => { test('custom assertion message', () => { const message = 'Custom error message'; - assert.throws(() => { + const assertThrowsTypeErrorWithMessage = (assertion: () => void) => { + // `node:assert` does not verify the error class when matching only on `{message}`. + assert.throws(assertion, error => { + assert.ok(error instanceof TypeError); + assert.strictEqual(error.message, message); + return true; + }); + }; + + assertThrowsTypeErrorWithMessage(() => { isAssert.array(undefined, undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.arrayBuffer(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.arrayLike(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.asyncFunction(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.asyncGenerator(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.asyncGeneratorFunction(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.asyncIterable(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.bigInt64Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.bigUint64Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.bigint(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.blob(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.boolean(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.boundFunction(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.buffer(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.class(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.dataView(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.date(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.directInstanceOf(undefined, Error, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptyArray(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptyMap(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptyObject(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptySet(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptyString(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.emptyStringOrWhitespace(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { enum Enum {} isAssert.enumCase('invalid', Enum, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.error(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.evenInteger(33, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.falsy(true, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.finiteNumber(Number.POSITIVE_INFINITY, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.float32Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.float64Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.formData(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.function(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.generator(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.generatorFunction(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.htmlElement(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.inRange(5, [1, 2], message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.infinite(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.int16Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.int32Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.int8Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.integer(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.iterable(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.map(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nan(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nativePromise(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.negativeNumber(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nodeStream(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptyArray(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptyMap(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptyObject(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptySet(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptyString(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonEmptyStringAndNotWhitespace(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nonNegativeNumber(-1, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.null(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.nullOrUndefined(false, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.number(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.numericString(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.object(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.observable(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.oddInteger(42, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.plainObject(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.positiveInteger(0, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.positiveNumber(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.primitive([], message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.promise(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.propertyKey(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.regExp(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.safeInteger(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.set(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.sharedArrayBuffer(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.string(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.symbol(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.truthy(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.tupleLike(undefined, [], message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.typedArray(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.uint16Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.uint32Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.uint8Array(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.uint8ClampedArray(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.undefined(false, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.urlInstance(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.urlSearchParams(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.urlString(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.validDate(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.validLength(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.weakMap(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.weakRef(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.weakSet(undefined, message); - }, {message}); + }); - assert.throws(() => { + assertThrowsTypeErrorWithMessage(() => { isAssert.whitespaceString(undefined, message); - }, {message}); + }); }); test('is.optional', () => { diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 0000000..1f492d3 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "..", + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": [ + "../source", + "type-tests.ts" + ] +} diff --git a/test/type-tests.ts b/test/type-tests.ts new file mode 100644 index 0000000..bb50e68 --- /dev/null +++ b/test/type-tests.ts @@ -0,0 +1,220 @@ +import is, { + type EvenInteger, + type FiniteNumber, + type Integer, + type NaN as NaNType, + type NegativeInfinity, + type NegativeInteger, + type NegativeNumber, + type NonNegativeInteger, + type NonNegativeNumber, + type OddInteger, + type PositiveInfinity, + type PositiveInteger, + type PositiveNumber, + type SafeInteger, + type ValidLength, +} from '../source/index.ts'; + +// For each predicate, verify two things: +// 1. True branch narrows to the branded type. +// 2. False branch on a `number` input stays `number` (not `never`). +// Without the branded types, `Exclude` = `never` would break +// the common validation-guard pattern: if (!is.X(n)) throw; use(n). + +const nanCheck = (value: number) => { + if (is.nan(value)) { + const _: NaNType = value; + } else { + const _: number = value; + } +}; + +const finiteNumberCheck = (value: number) => { + if (is.finiteNumber(value)) { + const _: FiniteNumber = value; + } else { + const _: number = value; + } +}; + +const nonNegativeNumberCheck = (value: number) => { + if (is.nonNegativeNumber(value)) { + const _: NonNegativeNumber = value; + } else { + const _: number = value; + } +}; + +const positiveIntegerCheck = (value: number) => { + if (is.positiveInteger(value)) { + const _: PositiveInteger = value; + const __: Integer = value; + const ___: NonNegativeInteger = value; + } else { + const _: number = value; + } +}; + +const negativeIntegerCheck = (value: number) => { + if (is.negativeInteger(value)) { + const _: NegativeInteger = value; + const __: Integer = value; + } else { + const _: number = value; + } +}; + +const nonNegativeIntegerCheck = (value: number) => { + if (is.nonNegativeInteger(value)) { + const _: NonNegativeInteger = value; + const __: Integer = value; + } else { + const _: number = value; + } +}; + +const infiniteCheck = (value: number) => { + if (is.infinite(value)) { + const _: PositiveInfinity | NegativeInfinity = value; + const __: PositiveNumber | NegativeNumber = value; + } else { + const _: number = value; + } +}; + +const integerCheck = (value: number) => { + if (is.integer(value)) { + const _: Integer = value; + const __: FiniteNumber = value; + } else { + const _: number = value; + } +}; + +const safeIntegerCheck = (value: number) => { + if (is.safeInteger(value)) { + const _: SafeInteger = value; + const __: Integer = value; + } else { + const _: number = value; + } +}; + +const evenIntegerCheck = (value: number) => { + if (is.evenInteger(value)) { + const _: EvenInteger = value; + const __: Integer = value; + } else { + const _: number = value; + } +}; + +const oddIntegerCheck = (value: number) => { + if (is.oddInteger(value)) { + const _: OddInteger = value; + const __: Integer = value; + } else { + const _: number = value; + } +}; + +const positiveNumberCheck = (value: number) => { + if (is.positiveNumber(value)) { + const _: PositiveNumber = value; + const __: NonNegativeNumber = value; + } else { + const _: number = value; + } +}; + +const negativeNumberCheck = (value: number) => { + if (is.negativeNumber(value)) { + const _: NegativeNumber = value; + } else { + const _: number = value; + } +}; + +const validLengthCheck = (value: number) => { + if (is.validLength(value)) { + const _: ValidLength = value; + const __: SafeInteger = value; + const ___: NonNegativeInteger = value; + } else { + const _: number = value; + } +}; + +const integerUnknownCheck = (value: unknown) => { + if (is.integer(value)) { + const _: Integer = value; + const __: FiniteNumber = value; + } +}; + +const positiveIntegerUnknownCheck = (value: unknown) => { + if (is.positiveInteger(value)) { + const _: PositiveInteger = value; + const __: NonNegativeInteger = value; + } +}; + +const integerMixedUnionCheck = (value: string | number) => { + if (is.integer(value)) { + const _: number = value; + } else { + const _: string = value; + } +}; + +const positiveNumberMixedUnionCheck = (value: string | number) => { + if (is.positiveNumber(value)) { + const _: number = value; + } else { + const _: string = value; + } +}; + +const chainedNumericGuardCheck = (value: number) => { + if (is.positiveNumber(value) && is.integer(value)) { + const _: PositiveNumber = value; + const __: Integer = value; + const ___: FiniteNumber = value; + } +}; + +const distinctNumericBrandsStayDistinct = ( + positiveInteger: PositiveInteger, + negativeInteger: NegativeInteger, + validLength: ValidLength, +) => { + // @ts-expect-error -- Distinct numeric refinements must not collapse into each other. + const _: NegativeInteger = positiveInteger; + // @ts-expect-error -- ValidLength is non-negative and must not become a signed integer refinement. + const __: NegativeInteger = validLength; + + return negativeInteger; +}; + +// Suppress unused variable warnings +nanCheck(42); +finiteNumberCheck(42); +nonNegativeNumberCheck(42); +positiveIntegerCheck(42); +negativeIntegerCheck(-1); +nonNegativeIntegerCheck(0); +infiniteCheck(Number.POSITIVE_INFINITY); +integerCheck(1); +safeIntegerCheck(1); +evenIntegerCheck(2); +oddIntegerCheck(1); +positiveNumberCheck(1); +negativeNumberCheck(-1); +validLengthCheck(0); +integerUnknownCheck(1); +positiveIntegerUnknownCheck(1); +integerMixedUnionCheck(1); +positiveNumberMixedUnionCheck(1); +chainedNumericGuardCheck(1); +distinctNumericBrandsStayDistinct(42 as PositiveInteger, -1 as NegativeInteger, 0 as ValidLength);