Fix some type guards

This commit is contained in:
Sindre Sorhus 2026-04-09 00:31:33 +07:00
parent cb4ee0e92c
commit 13febb6b01
8 changed files with 715 additions and 205 deletions

9
AGENTS.md Normal file
View file

@ -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<number, number>` = `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, Integer>` = `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`.

1
CLAUDE.md Symbolic link
View file

@ -0,0 +1 @@
AGENTS.md

View file

@ -21,7 +21,7 @@
}, },
"scripts": { "scripts": {
"build": "del distribution && tsc", "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" "prepare": "npm run build"
}, },
"files": [ "files": [

View file

@ -1,14 +1,29 @@
import type { import type {
ArrayLike, ArrayLike,
Class, Class,
EvenInteger,
Falsy, Falsy,
FiniteNumber,
Integer,
NaN as NaNType,
NegativeInfinity,
NegativeInteger,
NegativeNumber,
NodeStream, NodeStream,
NonEmptyString, NonEmptyString,
NonNegativeInteger,
NonNegativeNumber,
ObservableLike, ObservableLike,
OddInteger,
Predicate, Predicate,
Primitive, Primitive,
PositiveInfinity,
PositiveInteger,
PositiveNumber,
SafeInteger,
TypedArray, TypedArray,
UrlString, UrlString,
ValidLength,
WeakRef, WeakRef,
Whitespace, Whitespace,
} from './types.ts'; } from './types.ts';
@ -22,6 +37,15 @@ type ExtractFromGlobalConstructors<Name extends string> =
type NodeBuffer = ExtractFromGlobalConstructors<'Buffer'>; type NodeBuffer = ExtractFromGlobalConstructors<'Buffer'>;
type NumericGuardResult<Input, Branded extends number> =
(
unknown extends Input
? Branded
: Input extends number
? Branded & Input
: number
) & Input;
const typedArrayTypeNames = [ const typedArrayTypeNames = [
'Int8Array', 'Int8Array',
'Uint8Array', 'Uint8Array',
@ -99,6 +123,7 @@ function isPrimitiveTypeName(name: unknown): name is PrimitiveTypeName {
export type TypeName = ObjectTypeName | PrimitiveTypeName; export type TypeName = ObjectTypeName | PrimitiveTypeName;
const assertionTypeDescriptions = [ const assertionTypeDescriptions = [
'bound Function',
'positive number', 'positive number',
'negative number', 'negative number',
'Class', 'Class',
@ -139,6 +164,7 @@ const assertionTypeDescriptions = [
'non-negative number', 'non-negative number',
'odd integer', 'odd integer',
'positive integer', 'positive integer',
'safe integer',
'T', 'T',
'in range', 'in range',
'predicate returns truthy for any value', 'predicate returns truthy for any value',
@ -225,8 +251,7 @@ function detect(value: unknown): TypeName {
return 'Promise'; return 'Promise';
} }
const objectTag = Object.prototype.toString.call(value).slice(8, -1); if (isBoxedPrimitiveObject(value)) {
if (objectTag === 'String' || objectTag === 'Boolean' || objectTag === 'Number') {
throw new TypeError('Please don\'t use object wrappers for primitive types'); throw new TypeError('Please don\'t use object wrappers for primitive types');
} }
@ -237,6 +262,23 @@ function hasPromiseApi<T = unknown>(value: unknown): value is Promise<T> {
return isFunction((value as Promise<T>)?.then) && isFunction((value as Promise<T>)?.catch); return isFunction((value as Promise<T>)?.then) && isFunction((value as Promise<T>)?.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( const is = Object.assign(
detect, detect,
{ {
@ -560,11 +602,13 @@ export function isEnumCase<T = unknown>(value: unknown, targetEnum: T): value is
} }
export function isError(value: unknown): value is Error { 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'; 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<Input>(value: Input): value is NumericGuardResult<Input, EvenInteger>;
export function isEvenInteger(value: unknown): boolean {
return isAbsoluteModule2(0)(value); return isAbsoluteModule2(0)(value);
} }
@ -573,7 +617,8 @@ export function isFalsy(value: unknown): value is Falsy {
return !value; return !value;
} }
export function isFiniteNumber(value: unknown): value is number { export function isFiniteNumber<Input>(value: Input): value is NumericGuardResult<Input, FiniteNumber>;
export function isFiniteNumber(value: unknown): boolean {
return Number.isFinite(value); 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); && DOM_PROPERTIES_TO_CHECK.every(property => property in value);
} }
export function isInfinite(value: unknown): value is number { export function isInfinite<Input>(value: Input): value is NumericGuardResult<Input, PositiveInfinity | NegativeInfinity>;
export function isInfinite(value: unknown): boolean {
return value === Number.POSITIVE_INFINITY || value === Number.NEGATIVE_INFINITY; 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'; return getObjectType(value) === 'Int8Array';
} }
export function isInteger(value: unknown): value is number { export function isInteger<Input>(value: Input): value is NumericGuardResult<Input, Integer>;
export function isInteger(value: unknown): boolean {
return Number.isInteger(value); return Number.isInteger(value);
} }
@ -666,7 +713,8 @@ export function isMap<Key = unknown, Value = unknown>(value: unknown): value is
return getObjectType(value) === 'Map'; return getObjectType(value) === 'Map';
} }
export function isNan(value: unknown) { export function isNan<Input>(value: Input): value is NumericGuardResult<Input, NaNType>;
export function isNan(value: unknown): boolean {
return Number.isNaN(value); return Number.isNaN(value);
} }
@ -674,11 +722,13 @@ export function isNativePromise<T = unknown>(value: unknown): value is Promise<T
return getObjectType(value) === 'Promise'; return getObjectType(value) === 'Promise';
} }
export function isNegativeInteger(value: unknown): value is number { export function isNegativeInteger<Input>(value: Input): value is NumericGuardResult<Input, NegativeInteger>;
export function isNegativeInteger(value: unknown): boolean {
return isInteger(value) && value < 0; return isInteger(value) && value < 0;
} }
export function isNegativeNumber(value: unknown): value is number { export function isNegativeNumber<Input>(value: Input): value is NumericGuardResult<Input, NegativeNumber>;
export function isNegativeNumber(value: unknown): boolean {
return isNumber(value) && value < 0; return isNumber(value) && value < 0;
} }
@ -714,11 +764,13 @@ export function isNonEmptyStringAndNotWhitespace(value: unknown): value is NonEm
return isString(value) && !isEmptyStringOrWhitespace(value); return isString(value) && !isEmptyStringOrWhitespace(value);
} }
export function isNonNegativeInteger(value: unknown): value is number { export function isNonNegativeInteger<Input>(value: Input): value is NumericGuardResult<Input, NonNegativeInteger>;
export function isNonNegativeInteger(value: unknown): boolean {
return isInteger(value) && value >= 0; return isInteger(value) && value >= 0;
} }
export function isNonNegativeNumber(value: unknown): value is number { export function isNonNegativeNumber<Input>(value: Input): value is NumericGuardResult<Input, NonNegativeNumber>;
export function isNonNegativeNumber(value: unknown): boolean {
return isNumber(value) && value >= 0; return isNumber(value) && value >= 0;
} }
@ -763,7 +815,8 @@ export function isObservable(value: unknown): value is ObservableLike {
return false; return false;
} }
export function isOddInteger(value: unknown): value is number { export function isOddInteger<Input>(value: Input): value is NumericGuardResult<Input, OddInteger>;
export function isOddInteger(value: unknown): boolean {
return isAbsoluteModule2(1)(value); return isAbsoluteModule2(1)(value);
} }
@ -783,11 +836,13 @@ export function isPlainObject<Value = unknown>(value: unknown): value is Record<
return (prototype === null || prototype === Object.prototype || Object.getPrototypeOf(prototype) === null) && !(Symbol.toStringTag in value) && !(Symbol.iterator in value); 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<Input>(value: Input): value is NumericGuardResult<Input, PositiveInteger>;
export function isPositiveInteger(value: unknown): boolean {
return isInteger(value) && value > 0; return isInteger(value) && value > 0;
} }
export function isPositiveNumber(value: unknown): value is number { export function isPositiveNumber<Input>(value: Input): value is NumericGuardResult<Input, PositiveNumber>;
export function isPositiveNumber(value: unknown): boolean {
return isNumber(value) && value > 0; return isNumber(value) && value > 0;
} }
@ -808,7 +863,8 @@ export function isRegExp(value: unknown): value is RegExp {
return getObjectType(value) === 'RegExp'; return getObjectType(value) === 'RegExp';
} }
export function isSafeInteger(value: unknown): value is number { export function isSafeInteger<Input>(value: Input): value is NumericGuardResult<Input, SafeInteger>;
export function isSafeInteger(value: unknown): boolean {
return Number.isSafeInteger(value); return Number.isSafeInteger(value);
} }
@ -900,7 +956,8 @@ export function isValidDate(value: unknown): value is Date {
return isDate(value) && !isNan(Number(value)); return isDate(value) && !isNan(Number(value));
} }
export function isValidLength(value: unknown): value is number { export function isValidLength<Input>(value: Input): value is NumericGuardResult<Input, ValidLength>;
export function isValidLength(value: unknown): boolean {
return isSafeInteger(value) && value >= 0; return isSafeInteger(value) && value >= 0;
} }
@ -956,6 +1013,8 @@ function typeErrorMessageMultipleValues(expectedType: AssertionTypeDescription |
} }
// Type assertions have to be declared with an explicit type. // 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 = { type Assert = {
// Unknowns. // Unknowns.
undefined: (value: unknown, message?: string) => asserts value is undefined; undefined: (value: unknown, message?: string) => asserts value is undefined;
@ -1188,7 +1247,7 @@ const methodTypeMap = {
isBigUint64Array: 'BigUint64Array', isBigUint64Array: 'BigUint64Array',
isBlob: 'Blob', isBlob: 'Blob',
isBoolean: 'boolean', isBoolean: 'boolean',
isBoundFunction: 'Function', isBoundFunction: 'bound Function',
isBuffer: 'Buffer', isBuffer: 'Buffer',
isClass: 'Class', isClass: 'Class',
isDataView: 'DataView', isDataView: 'DataView',
@ -1247,7 +1306,7 @@ const methodTypeMap = {
isPromise: 'Promise', isPromise: 'Promise',
isPropertyKey: 'PropertyKey', isPropertyKey: 'PropertyKey',
isRegExp: 'RegExp', isRegExp: 'RegExp',
isSafeInteger: 'integer', isSafeInteger: 'safe integer',
isSet: 'Set', isSet: 'Set',
isSharedArrayBuffer: 'SharedArrayBuffer', isSharedArrayBuffer: 'SharedArrayBuffer',
isString: 'string', 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 // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
export function assertBoundFunction(value: unknown, message?: string): asserts value is Function { export function assertBoundFunction(value: unknown, message?: string): asserts value is Function {
if (!isBoundFunction(value)) { 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 { export function assertSafeInteger(value: unknown, message?: string): asserts value is number {
if (!isSafeInteger(value)) { 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 { export type {
ArrayLike, ArrayLike,
Class, Class,
EvenInteger,
FiniteNumber,
Integer,
NaN,
NegativeInfinity,
NegativeInteger,
NegativeNumber,
NodeStream, NodeStream,
NonNegativeInteger,
NonNegativeNumber,
ObservableLike, ObservableLike,
OddInteger,
PositiveInfinity,
PositiveInteger,
PositiveNumber,
Predicate, Predicate,
Primitive, Primitive,
SafeInteger,
TypedArray, TypedArray,
UrlString, UrlString,
ValidLength,
} from './types.ts'; } from './types.ts';

View file

@ -78,9 +78,122 @@ export type NonEmptyString = string & {0: string};
export type Whitespace = ' '; export type Whitespace = ' ';
type Brand<Key extends string> = Readonly<Record<Key, true>>;
/** /**
A string that represents a valid URL. A string that represents a valid URL.
This is a branded type to prevent incorrect TypeScript type narrowing. This is a branded type to prevent incorrect TypeScript type narrowing.
*/ */
export type UrlString = string & {readonly __brand: 'UrlString'}; 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'>;

View file

@ -15,6 +15,7 @@ import is, {
assert as isAssert, assert as isAssert,
assertPropertyKey, assertPropertyKey,
type AssertionTypeDescription, type AssertionTypeDescription,
type NaN as NaNType,
type Predicate, type Predicate,
type Primitive, type Primitive,
type TypedArray, type TypedArray,
@ -156,7 +157,7 @@ const primitiveTypes = {
safeInteger: { safeInteger: {
fixtures: [...reusableFixtures.integer, ...reusableFixtures.safeInteger], fixtures: [...reusableFixtures.integer, ...reusableFixtures.safeInteger],
typename: 'number', typename: 'number',
typeDescription: 'integer', typeDescription: 'safe integer',
}, },
infinite: { infinite: {
fixtures: [...reusableFixtures.infinite], fixtures: [...reusableFixtures.infinite],
@ -208,6 +209,7 @@ const objectTypes = {
object: { object: {
fixtures: [ fixtures: [
Object.create({x: 1}), Object.create({x: 1}),
{[Symbol.toStringTag]: 'String'},
...reusableFixtures.plainObject, ...reusableFixtures.plainObject,
], ],
typename: 'Object', typename: 'Object',
@ -280,6 +282,7 @@ const objectTypes = {
boundFunction: { boundFunction: {
fixtures: [...reusableFixtures.boundFunction, ...reusableFixtures.asyncFunction], fixtures: [...reusableFixtures.boundFunction, ...reusableFixtures.asyncFunction],
typename: 'Function', typename: 'Function',
typeDescription: 'bound Function',
}, },
map: { map: {
fixtures: [ fixtures: [
@ -526,6 +529,7 @@ test('is.positiveNumber', () => {
assert.strictEqual(is.positiveNumber(-6), false); assert.strictEqual(is.positiveNumber(-6), false);
assert.strictEqual(is.positiveNumber(-1.4), false); assert.strictEqual(is.positiveNumber(-1.4), false);
assert.strictEqual(is.positiveNumber(Number.NEGATIVE_INFINITY), false); assert.strictEqual(is.positiveNumber(Number.NEGATIVE_INFINITY), false);
assert.strictEqual(is.positiveNumber(Number.NaN), false);
assert.throws(() => { assert.throws(() => {
isAssert.positiveNumber(0); 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', () => { test('is.finiteNumber', () => {
assert.ok(is.finiteNumber(6)); assert.ok(is.finiteNumber(6));
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(6), false);
assert.strictEqual(is.negativeNumber(1.4), false); assert.strictEqual(is.negativeNumber(1.4), false);
assert.strictEqual(is.negativeNumber(Number.POSITIVE_INFINITY), false); assert.strictEqual(is.negativeNumber(Number.POSITIVE_INFINITY), false);
assert.strictEqual(is.negativeNumber(Number.NaN), false);
assert.throws(() => { assert.throws(() => {
isAssert.negativeNumber(0); 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', () => { test('is.numericString supplemental', () => {
assert.strictEqual(is.numericString(''), false); assert.strictEqual(is.numericString(''), false);
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<NaNType>();
expectTypeOf(value).toMatchTypeOf<number>();
} else {
// ✅ In false branch: value remains unknown (not incorrectly narrowed)
expectTypeOf(value).toEqualTypeOf<unknown>();
}
})();
test('is.truthy', () => { test('is.truthy', () => {
assert.ok(is.truthy('unicorn')); assert.ok(is.truthy('unicorn'));
assert.ok(is.truthy('🦄')); assert.ok(is.truthy('🦄'));
@ -2268,370 +2340,379 @@ test('assert', () => {
test('custom assertion message', () => { test('custom assertion message', () => {
const message = 'Custom error 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); isAssert.array(undefined, undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.arrayBuffer(undefined, message); isAssert.arrayBuffer(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.arrayLike(undefined, message); isAssert.arrayLike(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.asyncFunction(undefined, message); isAssert.asyncFunction(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.asyncGenerator(undefined, message); isAssert.asyncGenerator(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.asyncGeneratorFunction(undefined, message); isAssert.asyncGeneratorFunction(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.asyncIterable(undefined, message); isAssert.asyncIterable(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.bigInt64Array(undefined, message); isAssert.bigInt64Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.bigUint64Array(undefined, message); isAssert.bigUint64Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.bigint(undefined, message); isAssert.bigint(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.blob(undefined, message); isAssert.blob(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.boolean(undefined, message); isAssert.boolean(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.boundFunction(undefined, message); isAssert.boundFunction(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.buffer(undefined, message); isAssert.buffer(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.class(undefined, message); isAssert.class(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.dataView(undefined, message); isAssert.dataView(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.date(undefined, message); isAssert.date(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.directInstanceOf(undefined, Error, message); isAssert.directInstanceOf(undefined, Error, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptyArray(undefined, message); isAssert.emptyArray(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptyMap(undefined, message); isAssert.emptyMap(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptyObject(undefined, message); isAssert.emptyObject(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptySet(undefined, message); isAssert.emptySet(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptyString(undefined, message); isAssert.emptyString(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.emptyStringOrWhitespace(undefined, message); isAssert.emptyStringOrWhitespace(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
enum Enum {} enum Enum {}
isAssert.enumCase('invalid', Enum, message); isAssert.enumCase('invalid', Enum, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.error(undefined, message); isAssert.error(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.evenInteger(33, message); isAssert.evenInteger(33, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.falsy(true, message); isAssert.falsy(true, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.finiteNumber(Number.POSITIVE_INFINITY, message); isAssert.finiteNumber(Number.POSITIVE_INFINITY, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.float32Array(undefined, message); isAssert.float32Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.float64Array(undefined, message); isAssert.float64Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.formData(undefined, message); isAssert.formData(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.function(undefined, message); isAssert.function(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.generator(undefined, message); isAssert.generator(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.generatorFunction(undefined, message); isAssert.generatorFunction(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.htmlElement(undefined, message); isAssert.htmlElement(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.inRange(5, [1, 2], message); isAssert.inRange(5, [1, 2], message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.infinite(undefined, message); isAssert.infinite(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.int16Array(undefined, message); isAssert.int16Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.int32Array(undefined, message); isAssert.int32Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.int8Array(undefined, message); isAssert.int8Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.integer(undefined, message); isAssert.integer(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.iterable(undefined, message); isAssert.iterable(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.map(undefined, message); isAssert.map(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nan(undefined, message); isAssert.nan(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nativePromise(undefined, message); isAssert.nativePromise(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.negativeNumber(undefined, message); isAssert.negativeNumber(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nodeStream(undefined, message); isAssert.nodeStream(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptyArray(undefined, message); isAssert.nonEmptyArray(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptyMap(undefined, message); isAssert.nonEmptyMap(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptyObject(undefined, message); isAssert.nonEmptyObject(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptySet(undefined, message); isAssert.nonEmptySet(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptyString(undefined, message); isAssert.nonEmptyString(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonEmptyStringAndNotWhitespace(undefined, message); isAssert.nonEmptyStringAndNotWhitespace(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nonNegativeNumber(-1, message); isAssert.nonNegativeNumber(-1, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.null(undefined, message); isAssert.null(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.nullOrUndefined(false, message); isAssert.nullOrUndefined(false, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.number(undefined, message); isAssert.number(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.numericString(undefined, message); isAssert.numericString(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.object(undefined, message); isAssert.object(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.observable(undefined, message); isAssert.observable(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.oddInteger(42, message); isAssert.oddInteger(42, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.plainObject(undefined, message); isAssert.plainObject(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.positiveInteger(0, message); isAssert.positiveInteger(0, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.positiveNumber(undefined, message); isAssert.positiveNumber(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.primitive([], message); isAssert.primitive([], message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.promise(undefined, message); isAssert.promise(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.propertyKey(undefined, message); isAssert.propertyKey(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.regExp(undefined, message); isAssert.regExp(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.safeInteger(undefined, message); isAssert.safeInteger(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.set(undefined, message); isAssert.set(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.sharedArrayBuffer(undefined, message); isAssert.sharedArrayBuffer(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.string(undefined, message); isAssert.string(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.symbol(undefined, message); isAssert.symbol(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.truthy(undefined, message); isAssert.truthy(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.tupleLike(undefined, [], message); isAssert.tupleLike(undefined, [], message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.typedArray(undefined, message); isAssert.typedArray(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.uint16Array(undefined, message); isAssert.uint16Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.uint32Array(undefined, message); isAssert.uint32Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.uint8Array(undefined, message); isAssert.uint8Array(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.uint8ClampedArray(undefined, message); isAssert.uint8ClampedArray(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.undefined(false, message); isAssert.undefined(false, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.urlInstance(undefined, message); isAssert.urlInstance(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.urlSearchParams(undefined, message); isAssert.urlSearchParams(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.urlString(undefined, message); isAssert.urlString(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.validDate(undefined, message); isAssert.validDate(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.validLength(undefined, message); isAssert.validLength(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.weakMap(undefined, message); isAssert.weakMap(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.weakRef(undefined, message); isAssert.weakRef(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.weakSet(undefined, message); isAssert.weakSet(undefined, message);
}, {message}); });
assert.throws(() => { assertThrowsTypeErrorWithMessage(() => {
isAssert.whitespaceString(undefined, message); isAssert.whitespaceString(undefined, message);
}, {message}); });
}); });
test('is.optional', () => { test('is.optional', () => {

12
test/tsconfig.json Normal file
View file

@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"rootDir": "..",
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": [
"../source",
"type-tests.ts"
]
}

220
test/type-tests.ts Normal file
View file

@ -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<number, number>` = `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);