diff --git a/benchmarks/typed/isClass.bench.ts b/benchmarks/typed/isClass.bench.ts new file mode 100644 index 00000000..ed0d0f6b --- /dev/null +++ b/benchmarks/typed/isClass.bench.ts @@ -0,0 +1,11 @@ +import * as _ from 'radashi' + +describe('isClass', () => { + bench('with class', () => { + _.isClass(class CustomClass {}) + }) + + bench('with non-class', () => { + _.isClass({}) + }) +}) diff --git a/docs/typed/isClass.mdx b/docs/typed/isClass.mdx new file mode 100644 index 00000000..974c4cd9 --- /dev/null +++ b/docs/typed/isClass.mdx @@ -0,0 +1,33 @@ +--- +title: isClass +description: Determine if a value was declared with `class` syntax +--- + +### Usage + +This function returns `true` if the provided value is a constructor declared with the ES6 `class` keyword. + +```ts +import * as _ from 'radashi' + +class MyClass {} + +_.isClass(MyClass) // => true +_.isClass(Error) // => false +_.isClass(function OldClass() {}) // => false +_.isClass('abc') // => false +_.isClass({}) // => false +_.isClass(undefined) // => false +``` + +:::note + +Old school constructors (declared with the `function` keyword) will return `false`. + +Built-in class constructors (e.g. `Error`) will also return `false`, because they're created with native code, not the `class` keyword. + +::: + +### Popular use cases + +- Type guard would check for a function that must be called with the `new` operator diff --git a/src/mod.ts b/src/mod.ts index b1137a06..90e983fe 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -111,6 +111,7 @@ export * from './string/trim.ts' export * from './typed/isArray.ts' export * from './typed/isBoolean.ts' +export * from './typed/isClass.ts' export * from './typed/isDate.ts' export * from './typed/isEmpty.ts' export * from './typed/isEqual.ts' diff --git a/src/typed/isClass.ts b/src/typed/isClass.ts new file mode 100644 index 00000000..89cf5d43 --- /dev/null +++ b/src/typed/isClass.ts @@ -0,0 +1,34 @@ +import { isFunction, type Class, type StrictExtract } from 'radashi' + +/** + * Checks if the given value is a class. This function verifies + * if the value was defined using the `class` syntax. Old school + * classes (defined with constructor functions) will return false. + * "Native classes" like `Error` will also return false. + * + * @see https://radashi.js.org/reference/typed/isClass + * @example + * ```ts + * isClass(class CustomClass {}) // => true + * isClass('abc') // => false + * isClass({}) // => false + * ``` + */ +export function isClass(value: T): value is ExtractClass { + return ( + isFunction(value) && + Function.prototype.toString.call(value).startsWith('class ') + ) +} + +/** + * Used by the `isClass` type guard. It handles type narrowing for + * class constructors and even narrows `any` types. + */ +export type ExtractClass = [StrictExtract] extends [Class] + ? Extract + : T extends any + ? Class extends T + ? Class + : never + : never diff --git a/src/types.ts b/src/types.ts index 330c900c..71634555 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,13 @@ export declare class Any { private any: typeof any } +/** + * Represents a class constructor. + */ +export type Class = new ( + ...args: TArgs +) => TReturn + /** * Extracts `T` if `T` is not `any`, otherwise `never`. * diff --git a/tests/typed/isClass.test-d.ts b/tests/typed/isClass.test-d.ts new file mode 100644 index 00000000..766165fd --- /dev/null +++ b/tests/typed/isClass.test-d.ts @@ -0,0 +1,45 @@ +import type { Class } from 'radashi' +import * as _ from 'radashi' +import { expectTypeOf } from 'vitest' + +declare class Person { + name: string +} + +describe('isClass', () => { + test('value is union containing a class type', () => { + const value = {} as Person | typeof Person + if (_.isClass(value)) { + expectTypeOf(value).toEqualTypeOf() + expectTypeOf(new value()).toEqualTypeOf() + } else { + expectTypeOf(value).toEqualTypeOf() + } + }) + test('value is unknown', () => { + const value = {} as unknown + if (_.isClass(value)) { + expectTypeOf(value).toEqualTypeOf>() + expectTypeOf(new value()).toEqualTypeOf() + } else { + expectTypeOf(value).toEqualTypeOf() + } + }) + test('value is any', () => { + const value = {} as any + if (_.isClass(value)) { + expectTypeOf(value).toEqualTypeOf>() + expectTypeOf(new value()).toEqualTypeOf() + } else { + expectTypeOf(value).toEqualTypeOf() + } + }) + test('value is string', () => { + const value = {} as string + if (_.isClass(value)) { + expectTypeOf(value).toEqualTypeOf() + } else { + expectTypeOf(value).toEqualTypeOf() + } + }) +}) diff --git a/tests/typed/isClass.test.ts b/tests/typed/isClass.test.ts new file mode 100644 index 00000000..fef85b0e --- /dev/null +++ b/tests/typed/isClass.test.ts @@ -0,0 +1,39 @@ +import * as _ from 'radashi' + +function OldSchoolClass(something: string) { + // @ts-ignore + this.something = something +} +OldSchoolClass.prototype.doSomething = function () { + return `do ${this.something}` +} + +describe('isClass', () => { + test('returns false for non-Class values', () => { + const fn = () => {} + + class MyFunction extends Function { + toString() { + return 'class instance of MyFunction' + } + } + + expect(_.isClass(OldSchoolClass)).toBeFalsy() + expect(_.isClass(fn)).toBeFalsy() + expect(_.isClass(undefined)).toBeFalsy() + expect(_.isClass(null)).toBeFalsy() + expect(_.isClass(false)).toBeFalsy() + expect(_.isClass(() => {})).toBeFalsy() + expect(_.isClass(async () => {})).toBeFalsy() + expect(_.isClass(new MyFunction())).toBeFalsy() + expect(_.isClass(Number.NaN)).toBeFalsy() + expect(_.isClass([1, 2, 3])).toBeFalsy() + expect(_.isClass({})).toBeFalsy() + expect(_.isClass('abc')).toBeFalsy() + expect(_.isClass(String('abc'))).toBeFalsy() + }) + test('returns true for class values', () => { + expect(_.isClass(class CustomError extends Error {})).toBeTruthy() + expect(_.isClass(class CustomClass {})).toBeTruthy() + }) +})