diff --git a/.github/next-minor.md b/.github/next-minor.md index 542c8bcd..ec10386c 100644 --- a/.github/next-minor.md +++ b/.github/next-minor.md @@ -4,7 +4,9 @@ The `####` headline should be short and descriptive of the new functionality. In ## New Functions -#### +#### isClass + +https://github.com/radashi-org/radashi/pull/239 ## New Features 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..f1997fef --- /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 {} + +function OldSchoolClass() { + this.foo = 'bar' +} + +_.isClass(MyClass) // => true +_.isClass(Error) // => false +_.isClass(OldSchoolClass) // => 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. + +::: diff --git a/scripts/benchmarks/package.json b/scripts/benchmarks/package.json index 667f5f90..7c6afb7a 100644 --- a/scripts/benchmarks/package.json +++ b/scripts/benchmarks/package.json @@ -1,12 +1,12 @@ { "type": "module", "private": true, - "scripts": { - "test": "vitest" - }, "exports": { "./*": "./src/*" }, + "scripts": { + "test": "vitest" + }, "dependencies": { "@babel/parser": "^7.25.3", "@babel/traverse": "^7.25.3", diff --git a/scripts/common/package.json b/scripts/common/package.json index 3d808c12..a4b26eb0 100644 --- a/scripts/common/package.json +++ b/scripts/common/package.json @@ -4,4 +4,4 @@ "exports": { "./*": "./src/*" } -} \ No newline at end of file +} diff --git a/scripts/package.json b/scripts/package.json index e0f39903..094aa894 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -1,4 +1,4 @@ { "type": "module", "private": true -} \ No newline at end of file +} diff --git a/scripts/radashi-db/package.json b/scripts/radashi-db/package.json index 8fd269eb..3916fe2c 100644 --- a/scripts/radashi-db/package.json +++ b/scripts/radashi-db/package.json @@ -1,15 +1,15 @@ { "type": "module", "private": true, - "dependencies": { - "@supabase/supabase-js": "^2.45.0", - "algoliasearch": "^4.24.0", - "execa": "^9.5.1" - }, "exports": { "./*": "./src/*" }, "scripts": { "gen-types": "node --experimental-strip-types ./gen-types.ts" + }, + "dependencies": { + "@supabase/supabase-js": "^2.45.0", + "algoliasearch": "^4.24.0", + "execa": "^9.5.1" } } diff --git a/src/async/retry.ts b/src/async/retry.ts index a04f66f5..a0a143af 100644 --- a/src/async/retry.ts +++ b/src/async/retry.ts @@ -1,8 +1,8 @@ import { sleep, tryit } from 'radashi' -type AbortSignal = { - throwIfAborted(): void -} +type AbortSignal = { + throwIfAborted(): void +} export type RetryOptions = { times?: number diff --git a/src/mod.ts b/src/mod.ts index b1ac0dd6..d3f5a01e 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -109,6 +109,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() + }) +})