-
Notifications
You must be signed in to change notification settings - Fork 526
WIP: feat: add promises methods #1556
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| # promisify | ||
|
|
||
| Converts a callback-based function to a Promise-based function. | ||
|
|
||
| Takes a function that accepts a Node.js-style callback `(error, result) => void` as its last argument and returns a new function that returns a Promise. This is useful for modernizing legacy codebases that use callback patterns. | ||
|
|
||
| ## Signature | ||
|
|
||
| ```typescript | ||
| function promisify<Args extends unknown[], Result>( | ||
| fn: (...args: [...Args, Callback<Result>]) => void, | ||
| options?: PromisifyOptions | ||
| ): (...args: Args) => Promise<Result>; | ||
| ``` | ||
|
|
||
| ### Parameters | ||
|
|
||
| - `fn` (`(...args: [...Args, Callback<Result>]) => void`): A function that accepts a callback as its last argument. The callback should follow the Node.js convention: `(error, result) => void`. | ||
| - `options` (`PromisifyOptions`, optional): Configuration options. | ||
| - `context` (`object`, optional): The `this` context to bind when calling the function. Useful for object methods. | ||
|
|
||
| ### Returns | ||
|
|
||
| (`(...args: Args) => Promise<Result>`): A new function that returns a Promise. The Promise resolves with the callback's result value, or rejects with the callback's error. | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Basic usage | ||
|
|
||
| ```typescript | ||
| import { promisify } from 'es-toolkit/promise'; | ||
|
|
||
| function readFile(path: string, callback: (err: Error | null, data: string) => void) { | ||
| // simulate async file reading | ||
| setTimeout(() => callback(null, 'file content'), 100); | ||
| } | ||
|
|
||
| const readFileAsync = promisify(readFile); | ||
| const data = await readFileAsync('example.txt'); | ||
| console.log(data); // 'file content' | ||
| ``` | ||
|
|
||
| ### With context binding | ||
|
|
||
| When working with object methods that depend on `this`, use the `context` option: | ||
|
|
||
| ```typescript | ||
| import { promisify } from 'es-toolkit/promise'; | ||
|
|
||
| const redis = { | ||
| host: 'localhost', | ||
| get(key: string, callback: (err: Error | null, value: string) => void) { | ||
| // uses this.host | ||
| callback(null, `value from ${this.host}`); | ||
| }, | ||
| }; | ||
|
|
||
| // Without context, 'this' would be undefined | ||
| const redisGet = promisify(redis.get, { context: redis }); | ||
| const value = await redisGet('myKey'); | ||
| console.log(value); // 'value from localhost' | ||
| ``` | ||
|
|
||
| ### Error handling | ||
|
|
||
| Errors passed to the callback are converted to Promise rejections: | ||
|
|
||
| ```typescript | ||
| import { promisify } from 'es-toolkit/promise'; | ||
|
|
||
| function failingOperation(callback: (err: Error | null, result: string) => void) { | ||
| callback(new Error('Operation failed'), ''); | ||
| } | ||
|
|
||
| const failingOperationAsync = promisify(failingOperation); | ||
|
|
||
| try { | ||
| await failingOperationAsync(); | ||
| } catch (error) { | ||
| console.error(error.message); // 'Operation failed' | ||
| } | ||
| ``` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| import { describe, it, expect, vi } from 'vitest'; | ||
| import { asCallback, nodeify } from './asCallback'; | ||
|
|
||
| describe('asCallback', () => { | ||
| it('invokes callback with result on success', async () => { | ||
| const cb = vi.fn(); | ||
| await asCallback(Promise.resolve(42), cb); | ||
| await Promise.resolve(); | ||
| expect(cb).toHaveBeenCalledWith(null, 42); | ||
| }); | ||
|
|
||
| it('invokes callback with error on rejection', async () => { | ||
| const cb = vi.fn(); | ||
| const error = new Error('test error'); | ||
| const promise = Promise.reject(error); | ||
|
|
||
| asCallback(promise, cb).catch(() => { | ||
| // Suppress unhandled rejection | ||
| }); | ||
| await Promise.resolve(); | ||
|
|
||
| expect(cb).toHaveBeenCalledWith(error, undefined); | ||
| }); | ||
|
|
||
| it('converts non-Error rejections to Error instances', async () => { | ||
| const cb = vi.fn(); | ||
| const promise = Promise.reject('string error'); | ||
|
|
||
| asCallback(promise, cb).catch(() => { | ||
| // Suppress unhandled rejection | ||
| }); | ||
| await Promise.resolve(); | ||
|
|
||
| expect(cb).toHaveBeenCalled(); | ||
| const [err] = cb.mock.calls[0]; | ||
| expect(err).toBeInstanceOf(Error); | ||
| expect(err.message).toBe('string error'); | ||
| }); | ||
|
|
||
| it('returns the original promise for chaining', async () => { | ||
| const cb = vi.fn(); | ||
| const promise = Promise.resolve('value'); | ||
| const result = asCallback(promise, cb); | ||
| expect(result).toBe(promise); | ||
| }); | ||
|
|
||
| it('nodeify is an alias for asCallback', () => { | ||
| expect(nodeify).toBe(asCallback); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| /** | ||
| * A Node.js-style callback function type. | ||
| * | ||
| * @template Result - The type of the result value on success. | ||
| */ | ||
| export type NodeStyleCallback<Result> = (err: Error | null, result: Result) => void; | ||
|
|
||
| /** | ||
| * Options for the asCallback function. | ||
| */ | ||
| export interface AsCallbackOptions { | ||
| /** | ||
| * If true, errors thrown in the callback won't be re-thrown. | ||
| * @default false | ||
| */ | ||
| suppressErrors?: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Registers a Node.js-style callback on a promise. | ||
| * | ||
| * This function attaches a callback to a promise, invoking it when the promise | ||
| * settles. On success, the callback receives `(null, result)`. On failure, it | ||
| * receives `(error, undefined)`. | ||
| * | ||
| * @template Result - The type of the resolved value. | ||
| * @param {Promise<Result>} promise - The promise to attach the callback to. | ||
| * @param {NodeStyleCallback<Result>} callback - The Node.js-style callback function. | ||
| * @returns {Promise<Result>} The original promise (for chaining). | ||
| * | ||
| * @example | ||
| * // Basic usage | ||
| * import { asCallback } from 'es-toolkit/promise'; | ||
| * | ||
| * const promise = Promise.resolve(42); | ||
| * asCallback(promise, (err, result) => { | ||
| * if (err) { | ||
| * console.error('Error:', err); | ||
| * } else { | ||
| * console.log('Result:', result); // Result: 42 | ||
| * } | ||
| * }); | ||
| * | ||
| * @example | ||
| * // Error handling | ||
| * const failingPromise = Promise.reject(new Error('Something went wrong')); | ||
| * asCallback(failingPromise, (err, result) => { | ||
| * if (err) { | ||
| * console.error('Error:', err.message); // Error: Something went wrong | ||
| * } | ||
| * }); | ||
| */ | ||
| export function asCallback<Result>( | ||
| promise: Promise<Result>, | ||
| callback: NodeStyleCallback<Result> | ||
| ): Promise<Result> { | ||
|
Comment on lines
+53
to
+56
|
||
| promise.then( | ||
| result => { | ||
| queueMicrotask(() => callback(null, result)); | ||
| }, | ||
| err => { | ||
| const error = err instanceof Error ? err : new Error(String(err)); | ||
| queueMicrotask(() => callback(error, undefined as Result)); | ||
|
||
| } | ||
| ); | ||
| return promise; | ||
| } | ||
|
|
||
| /** | ||
| * Alias for asCallback. | ||
| * | ||
| * @see {@link asCallback} | ||
| */ | ||
| export const nodeify = asCallback; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { asCallbackAll } from './asCallbackAll'; | ||
|
|
||
| describe('asCallbackAll', () => { | ||
| it('creates Callback methods from async methods', () => | ||
| new Promise<void>(done => { | ||
| const api = { | ||
| async echo(msg: string): Promise<string> { | ||
| return msg; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api); | ||
| callbackApi.echoCallback('hello', (err, result) => { | ||
| expect(err).toBeNull(); | ||
| expect(result).toBe('hello'); | ||
| done(); | ||
| }); | ||
| })); | ||
|
|
||
| it('preserves original async methods', async () => { | ||
| const api = { | ||
| async echo(msg: string): Promise<string> { | ||
| return msg; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api); | ||
|
|
||
| // Original async method still works | ||
| await expect(callbackApi.echo('test')).resolves.toBe('test'); | ||
| }); | ||
|
|
||
| it('handles errors correctly', () => | ||
| new Promise<void>(done => { | ||
| const api = { | ||
| async fail(): Promise<string> { | ||
| throw new Error('test error'); | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api); | ||
| callbackApi.failCallback((err, _result) => { | ||
| expect(err).toBeInstanceOf(Error); | ||
| expect(err?.message).toBe('test error'); | ||
| done(); | ||
| }); | ||
| })); | ||
|
|
||
| it('respects exclude option', () => { | ||
| const api = { | ||
| async included(): Promise<string> { | ||
| return 'included'; | ||
| }, | ||
| async excluded(): Promise<string> { | ||
| return 'excluded'; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api, { exclude: ['excluded'] }); | ||
| expect('includedCallback' in callbackApi).toBe(true); | ||
| expect('excludedCallback' in callbackApi).toBe(false); | ||
| }); | ||
|
|
||
| it('respects include option', () => { | ||
| const api = { | ||
| async included(): Promise<string> { | ||
| return 'included'; | ||
| }, | ||
| async notIncluded(): Promise<string> { | ||
| return 'not included'; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api, { include: ['included'] }); | ||
| expect('includedCallback' in callbackApi).toBe(true); | ||
| expect('notIncludedCallback' in callbackApi).toBe(false); | ||
| }); | ||
|
|
||
| it('uses custom suffix', () => { | ||
| const api = { | ||
| async echo(msg: string): Promise<string> { | ||
| return msg; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api, { suffix: 'Cb' }); | ||
| expect('echoCb' in callbackApi).toBe(true); | ||
| expect('echoCallback' in callbackApi).toBe(false); | ||
| }); | ||
|
|
||
| it('preserves context', () => | ||
| new Promise<void>(done => { | ||
| const api = { | ||
| value: 42, | ||
| async getValue(): Promise<number> { | ||
| return this.value; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api); | ||
| callbackApi.getValueCallback((err, result) => { | ||
| expect(err).toBeNull(); | ||
| expect(result).toBe(42); | ||
| done(); | ||
| }); | ||
| })); | ||
|
|
||
| it('works with multiple arguments', () => | ||
| new Promise<void>(done => { | ||
| const api = { | ||
| async add(a: number, b: number): Promise<number> { | ||
| return a + b; | ||
| }, | ||
| }; | ||
| const callbackApi = asCallbackAll(api); | ||
| callbackApi.addCallback(5, 3, (err, result) => { | ||
| expect(err).toBeNull(); | ||
| expect(result).toBe(8); | ||
| done(); | ||
| }); | ||
| })); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
AsCallbackOptionsinterface defines asuppressErrorsoption, but this option is never used in theasCallbackfunction implementation. The option should either be implemented or removed from the interface to avoid confusing users who might try to use this non-functional feature.