diff --git a/README.md b/README.md index 3c0eb9a..b260b36 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ main(); ## Supported endpoints We are striving to support more endpoints, but currently, there are many unsupported endpoints. If the endpoint you want to use is not supported, please open an [issue](https://github.com/suzuki3jp/youtubes.js/issues/new) to request it. We plan to prioritize adding support for the most requested endpoints. --: Not available in YouTube Data API +-: Not available in YouTube Data API ×: Not supported ⚠️: Partially supported ✅: Fully supported diff --git a/docs/01-introduction.md b/docs/01-introduction.md index 49ac5d2..788d58d 100644 --- a/docs/01-introduction.md +++ b/docs/01-introduction.md @@ -38,9 +38,9 @@ main(); ``` ## Handling errors -All public methods of `youtubes.js` return `Result` of [`result4js`](https://github.com/suzuki3jp/result4js). -Using `Result` enables type-safe error handling. -> If you want to abandon type safety and perform dangerous error handling, you can use [`Result#throw`](https://github.com/suzuki3jp/result4js?tab=readme-ov-file#usage). +All public methods of `youtubes.js` return `Result` of [`neverthrow`](https://github.com/supermacro/neverthrow). +Using `Result` enables type-safe error handling. +While developers familiar with JavaScript might find `Result`-based error handling cumbersome at first, it will dramatically improve application reliability and substantially enhance the development experience through type safety. ```ts import { ApiClient, StaticOAuthProvider } from "youtubes.js"; @@ -58,7 +58,7 @@ async function main() { return; } - const playlists = playlistsResult.data; + const playlists = playlistsResult.value; } main(); diff --git a/docs/02-usecases.md b/docs/02-usecases.md index d0c320c..a4efd0b 100644 --- a/docs/02-usecases.md +++ b/docs/02-usecases.md @@ -1,7 +1,7 @@ # Use Cases ## Note -All methods in `youtubes.js` return [`Result`](https://github.com/suzuki3jp/result4js) for safe error handling. +All methods in `youtubes.js` return [`Result`](https://github.com/supermacro/neverthrow) for safe error handling. Learn more about error handling with `Result` [here](./01-introduction.md#handling-errors). ## Pagination @@ -34,7 +34,7 @@ const allPages = await playlists.all() // Result=18" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "^4.24.0" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -7830,12 +7841,6 @@ "node": ">=8" } }, - "node_modules/result4js": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/result4js/-/result4js-1.1.0.tgz", - "integrity": "sha512-KE+Xi1OUW4EhFFGLOVhjAn7Y8tn9snrutDFSv0Yam/KECkmBEdSlhcbgnR2CRSOBpnfLCs/Exe90bHu7sAWBGw==", - "license": "MIT" - }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", diff --git a/package.json b/package.json index 4f277ff..a54a2bd 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,6 @@ }, "dependencies": { "googleapis": "^144.0.0", - "result4js": "1.1.0" + "neverthrow": "^8.1.1" } } diff --git a/src/OAuthProvider.ts b/src/OAuthProvider.ts index e3d2600..aac3108 100644 --- a/src/OAuthProvider.ts +++ b/src/OAuthProvider.ts @@ -1,4 +1,5 @@ import { google } from "googleapis"; + import type { OAuth2Client } from "./types"; export type OAuthProviders = StaticOAuthProvider; diff --git a/src/Pagination.ts b/src/Pagination.ts index b9bad72..bc60c92 100644 --- a/src/Pagination.ts +++ b/src/Pagination.ts @@ -1,4 +1,4 @@ -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "./Logger"; import { LIKELY_BUG } from "./constants"; @@ -91,12 +91,12 @@ export class Pagination { * }); * const client = new ApiClient({ oauth }); * - * - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getMine()).throw(); - * console.log(playlists.data); // The first page of playlists - * const prevPage = (await playlists.prev()).throw(); - * console.log(prevPage?.data); // The previous page of playlists or null if there is no previous page + * const playlists = await client.playlists.getMine(); + * if (playlists.isErr()) return; + * console.log(playlists.value); // The first page of playlists + * const prevPage = await playlists.prev(); + * if (prevPage?.isErr()) return; + * console.log(prevPage?.value); // The previous page of playlists or null if there is no previous page * ``` */ public async prev(): Promise { * }); * const client = new ApiClient({ oauth }); * - * - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getMine()).throw(); - * console.log(playlists.data); // The first page of playlists - * const nextPage = (await playlists.next()).throw(); - * console.log(nextPage?.data); // The second page of playlists or null if there is no next page + * const playlists = await client.playlists.getMine(); + * if (playlists.isErr()) return; + * console.log(playlists.value); // The first page of playlists + * const nextPage = await playlists.next(); + * if (nextPage?.isErr()) return; + * console.log(nextPage?.value); // The second page of playlists or null if there is no next page * ``` */ public async next(): Promise { * }); * const client = new ApiClient({ oauth }); * - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getMine()).throw(); - * const allPlaylists = (await playlists.all()).throw().flat(); + * const playlists = await client.playlists.getMine(); + * if (playlists.isErr()) { + * // Handle the error + * return; + * } + * const allPlaylists = (await playlists.all()).flat(); * ``` */ public async all(): Promise> { @@ -170,19 +173,19 @@ export class Pagination { let prev = await this.prev(); while (prev) { - if (prev.isErr()) return Err(prev.data); - result.unshift(prev.data.data); - prev = await prev.data.prev(); + if (prev.isErr()) return err(prev.error); + result.unshift(prev.value.data); + prev = await prev.value.prev(); } let next = await this.next(); while (next) { - if (next.isErr()) return Err(next.data); - result.push(next.data.data); - next = await next.data.next(); + if (next.isErr()) return err(next.error); + result.push(next.value.data); + next = await next.value.next(); } - return Ok(result); + return ok(result); } } diff --git a/src/entities/playlist-item.ts b/src/entities/playlist-item.ts index 29227de..25e95ec 100644 --- a/src/entities/playlist-item.ts +++ b/src/entities/playlist-item.ts @@ -1,5 +1,5 @@ import type { youtube_v3 } from "googleapis"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "../Logger"; import { LikelyBugError } from "../errors"; @@ -45,7 +45,7 @@ export function playlistItemFrom( data.status?.privacyStatus === "private" && Object.keys(data.snippet?.thumbnails ?? {}).length === 0 ) - return Ok(new UnavailablePlaylistItem()); + return ok(new UnavailablePlaylistItem()); if ( isNullish(data.id) || @@ -65,7 +65,7 @@ export function playlistItemFrom( currentLogger.debug("Raw data:"); currentLogger.debug(JSON.stringify(data, null, "\t")); - return Err( + return err( new LikelyBugError( "The raw data is missing required fields of a playlist data.", ), @@ -74,16 +74,16 @@ export function playlistItemFrom( const thumbnails = Thumbnails.from(data.snippet.thumbnails, currentLogger); const privacy = convertToPrivacy(data.status.privacyStatus); - if (thumbnails.isErr()) return Err(thumbnails.data); - if (privacy.isErr()) return Err(privacy.data); + if (thumbnails.isErr()) return err(thumbnails.error); + if (privacy.isErr()) return err(privacy.error); - return Ok( + return ok( new AvailablePlaylistItem({ id: data.id, playlistId: data.snippet.playlistId, title: data.snippet.title, description: data.snippet.description, - thumbnails: thumbnails.data, + thumbnails: thumbnails.value, channelId: data.snippet.channelId, channelName: data.snippet.channelTitle, videoId: data.snippet.resourceId.videoId, @@ -101,12 +101,12 @@ export function playlistItemFromMany( const playlistItems: PlaylistItem[] = []; for (const item of data) { const playlistItem = playlistItemFrom(item, logger); - if (playlistItem.isErr()) return Err(playlistItem.data); + if (playlistItem.isErr()) return err(playlistItem.error); - playlistItems.push(playlistItem.data); + playlistItems.push(playlistItem.value); } - return Ok(playlistItems); + return ok(playlistItems); } /** diff --git a/src/entities/playlist.ts b/src/entities/playlist.ts index 2fd55b6..f3ee056 100644 --- a/src/entities/playlist.ts +++ b/src/entities/playlist.ts @@ -1,5 +1,5 @@ import type { youtube_v3 } from "googleapis"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "../Logger"; import { LikelyBugError } from "../errors"; @@ -102,7 +102,7 @@ export class Playlist { currentLogger.debug("Generating Playlist instance from raw data."); currentLogger.debug("Raw data:"); currentLogger.debug(JSON.stringify(data, null, "\t")); - return Err(new LikelyBugError(message)); + return err(new LikelyBugError(message)); } const thumbnails = Thumbnails.from( @@ -110,16 +110,16 @@ export class Playlist { currentLogger, ); const privacy = convertToPrivacy(data.status.privacyStatus); - if (privacy.isErr()) return Err(privacy.data); - if (thumbnails.isErr()) return Err(thumbnails.data); + if (privacy.isErr()) return err(privacy.error); + if (thumbnails.isErr()) return err(thumbnails.error); - return Ok( + return ok( new Playlist({ id: data.id, title: data.snippet.title, description: data.snippet.description, - thumbnails: thumbnails.data, - privacy: privacy.data, + thumbnails: thumbnails.value, + privacy: privacy.value, count: data.contentDetails.itemCount, publishedAt: new Date(data.snippet.publishedAt), channelId: data.snippet.channelId, @@ -138,12 +138,12 @@ export class Playlist { for (const playlist of data) { const result = Playlist.from(playlist, currentLogger); if (result.isErr()) { - return Err(result.data); + return err(result.error); } - playlists.push(result.data); + playlists.push(result.value); } - return Ok(playlists); + return ok(playlists); } } diff --git a/src/entities/privacy.ts b/src/entities/privacy.ts index 5e5c5fb..0ab4b60 100644 --- a/src/entities/privacy.ts +++ b/src/entities/privacy.ts @@ -1,4 +1,4 @@ -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import { LikelyBugError } from "../errors"; @@ -11,17 +11,17 @@ export type Privacy = "public" | "unlisted" | "private"; export function convertToPrivacy( data?: string, ): Result { - if (!data) return Err(new LikelyBugError("The raw data is undefined.")); + if (!data) return err(new LikelyBugError("The raw data is undefined.")); switch (data) { case "public": - return Ok("public" as Privacy); + return ok("public"); case "unlisted": - return Ok("unlisted" as Privacy); + return ok("unlisted"); case "private": - return Ok("private" as Privacy); + return ok("private"); default: - return Err( + return err( new LikelyBugError( `The raw data is unexpected format. Expected "public", "unlisted", or "private". Received: ${data}`, ), diff --git a/src/entities/thumbnails.ts b/src/entities/thumbnails.ts index d8a7122..16a7f2d 100644 --- a/src/entities/thumbnails.ts +++ b/src/entities/thumbnails.ts @@ -1,5 +1,5 @@ import type { youtube_v3 } from "googleapis"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "../Logger"; import { LikelyBugError } from "../errors"; @@ -53,14 +53,14 @@ export class Thumbnails { currentLogger.debug("Raw data:"); currentLogger.debug(JSON.stringify(data, null, "\t")); - return Err( + return err( new LikelyBugError( "The raw data is missing required fields. Each thumbnail (default, medium, high, standard, maxres) must include url, width, and height.", ), ); } - return Ok(new Thumbnails(data as ThumbnailsData)); + return ok(new Thumbnails(data as ThumbnailsData)); } /** diff --git a/src/managers/PlaylistItemManager.ts b/src/managers/PlaylistItemManager.ts index 2e74411..6e5db07 100644 --- a/src/managers/PlaylistItemManager.ts +++ b/src/managers/PlaylistItemManager.ts @@ -1,5 +1,5 @@ import { google } from "googleapis"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "../Logger"; import type { OAuthProviders } from "../OAuthProvider"; @@ -54,20 +54,20 @@ export class PlaylistItemManager { pageToken, }), ); - if (rawData.isErr()) return Err(rawData.data); - if (isNullish(rawData.data.items)) - return Err(new LikelyBugError("The raw data is missing items")); - const items = playlistItemFromMany(rawData.data.items, this.logger); - if (items.isErr()) return Err(items.data); + if (rawData.isErr()) return err(rawData.error); + if (isNullish(rawData.value.items)) + return err(new LikelyBugError("The raw data is missing items")); + const items = playlistItemFromMany(rawData.value.items, this.logger); + if (items.isErr()) return err(items.error); - return Ok( + return ok( new Pagination({ - data: items.data, + data: items.value, logger: this.logger, - prevToken: rawData.data.prevPageToken, - nextToken: rawData.data.nextPageToken, - resultsPerPage: rawData.data.pageInfo?.resultsPerPage, - totalResults: rawData.data.pageInfo?.totalResults, + prevToken: rawData.value.prevPageToken, + nextToken: rawData.value.nextPageToken, + resultsPerPage: rawData.value.pageInfo?.resultsPerPage, + totalResults: rawData.value.pageInfo?.totalResults, getWithToken: (token) => this.getByPlaylistId(playlistId, token), }), @@ -100,11 +100,11 @@ export class PlaylistItemManager { }, }), ); - if (rawData.isErr()) return Err(rawData.data); - const item = playlistItemFrom(rawData.data, this.logger); - if (item.isErr()) return Err(item.data); + if (rawData.isErr()) return err(rawData.error); + const item = playlistItemFrom(rawData.value, this.logger); + if (item.isErr()) return err(item.error); - return Ok(item.data); + return ok(item.value); } /** @@ -119,9 +119,9 @@ export class PlaylistItemManager { id, }), ); - if (rawData.isErr()) return Err(rawData.data); + if (rawData.isErr()) return err(rawData.error); - return Ok(undefined); + return ok(undefined); } } diff --git a/src/managers/PlaylistManager.ts b/src/managers/PlaylistManager.ts index 53755c4..a64a633 100644 --- a/src/managers/PlaylistManager.ts +++ b/src/managers/PlaylistManager.ts @@ -1,5 +1,5 @@ import { google } from "googleapis"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import type { Logger } from "../Logger"; import type { OAuthProviders } from "../OAuthProvider"; @@ -46,8 +46,7 @@ export class PlaylistManager { * }); * * const client = new ApiClient({ oauth }); - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getMine()).throw(); // Pagination + * const playlists = await client.playlists.getMine(); // Result, YouTubesJsErrors> * ``` */ public async getMine( @@ -61,20 +60,20 @@ export class PlaylistManager { pageToken, }), ); - if (rawData.isErr()) return Err(rawData.data); - if (!rawData.data.items) - return Err(new LikelyBugError("The raw data is missing items.")); - const playlists = Playlist.fromMany(rawData.data.items, this.logger); - if (playlists.isErr()) return Err(playlists.data); + if (rawData.isErr()) return err(rawData.error); + if (!rawData.value.items) + return err(new LikelyBugError("The raw data is missing items.")); + const playlists = Playlist.fromMany(rawData.value.items, this.logger); + if (playlists.isErr()) return err(playlists.error); - return Ok( + return ok( new Pagination({ - data: playlists.data, + data: playlists.value, logger: this.logger, - prevToken: rawData.data.prevPageToken, - nextToken: rawData.data.nextPageToken, - resultsPerPage: rawData.data.pageInfo?.resultsPerPage, - totalResults: rawData.data.pageInfo?.totalResults, + prevToken: rawData.value.prevPageToken, + nextToken: rawData.value.nextPageToken, + resultsPerPage: rawData.value.pageInfo?.resultsPerPage, + totalResults: rawData.value.pageInfo?.totalResults, getWithToken: (token) => this.getMine(token), }), ); @@ -98,9 +97,7 @@ export class PlaylistManager { * accessToken: "ACCESS_TOKEN", * }); * const client = new ApiClient({ oauth }); - * - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getByIds(["ID1", "ID2"])).throw(); // Pagination + * const playlists = await client.playlists.getByIds(["ID1", "ID2"]) // Result, YouTubesJsErrors> * ``` */ public async getByIds( @@ -115,20 +112,20 @@ export class PlaylistManager { pageToken, }), ); - if (rawData.isErr()) return Err(rawData.data); - if (!rawData.data.items) - return Err(new LikelyBugError("The raw data is missing items.")); - const playlists = Playlist.fromMany(rawData.data.items, this.logger); - if (playlists.isErr()) return Err(playlists.data); + if (rawData.isErr()) return err(rawData.error); + if (!rawData.value.items) + return err(new LikelyBugError("The raw data is missing items.")); + const playlists = Playlist.fromMany(rawData.value.items, this.logger); + if (playlists.isErr()) return err(playlists.error); - return Ok( + return ok( new Pagination({ - data: playlists.data, + data: playlists.value, logger: this.logger, - prevToken: rawData.data.prevPageToken, - nextToken: rawData.data.nextPageToken, - resultsPerPage: rawData.data.pageInfo?.resultsPerPage, - totalResults: rawData.data.pageInfo?.totalResults, + prevToken: rawData.value.prevPageToken, + nextToken: rawData.value.nextPageToken, + resultsPerPage: rawData.value.pageInfo?.resultsPerPage, + totalResults: rawData.value.pageInfo?.totalResults, getWithToken: (token) => this.getByIds(ids, token), }), ); @@ -152,9 +149,7 @@ export class PlaylistManager { * accessToken: "ACCESS_TOKEN", * }); * const client = new ApiClient({ oauth }); - * - * // THIS IS UNSAFE ERROR HANDLING. See the safe error handling in the README.md Introduction. - * const playlists = (await client.playlists.getByChannelId("CHANNEL_ID")).throw(); // Pagination + * const playlists = await client.playlists.getByChannelId("CHANNEL_ID"); // Result, YouTubesJsErrors> * ``` */ public async getByChannelId( @@ -169,20 +164,20 @@ export class PlaylistManager { pageToken, }), ); - if (rawData.isErr()) return Err(rawData.data); - if (!rawData.data.items) - return Err(new LikelyBugError("The raw data is missing items.")); - const playlists = Playlist.fromMany(rawData.data.items, this.logger); - if (playlists.isErr()) return Err(playlists.data); + if (rawData.isErr()) return err(rawData.error); + if (!rawData.value.items) + return err(new LikelyBugError("The raw data is missing items.")); + const playlists = Playlist.fromMany(rawData.value.items, this.logger); + if (playlists.isErr()) return err(playlists.error); - return Ok( + return ok( new Pagination({ - data: playlists.data, + data: playlists.value, logger: this.logger, - prevToken: rawData.data.prevPageToken, - nextToken: rawData.data.nextPageToken, - resultsPerPage: rawData.data.pageInfo?.resultsPerPage, - totalResults: rawData.data.pageInfo?.totalResults, + prevToken: rawData.value.prevPageToken, + nextToken: rawData.value.nextPageToken, + resultsPerPage: rawData.value.pageInfo?.resultsPerPage, + totalResults: rawData.value.pageInfo?.totalResults, getWithToken: (token) => this.getByChannelId(id, token), }), ); @@ -220,11 +215,11 @@ export class PlaylistManager { }, }), ); - if (rawData.isErr()) return Err(rawData.data); - const playlist = Playlist.from(rawData.data, this.logger); - if (playlist.isErr()) return Err(playlist.data); + if (rawData.isErr()) return err(rawData.error); + const playlist = Playlist.from(rawData.value, this.logger); + if (playlist.isErr()) return err(playlist.error); - return Ok(playlist.data); + return ok(playlist.value); } /** @@ -268,11 +263,11 @@ export class PlaylistManager { }, }), ); - if (rawData.isErr()) return Err(rawData.data); - const playlist = Playlist.from(rawData.data, this.logger); - if (playlist.isErr()) return Err(playlist.data); + if (rawData.isErr()) return err(rawData.error); + const playlist = Playlist.from(rawData.value, this.logger); + if (playlist.isErr()) return err(playlist.error); - return Ok(playlist.data); + return ok(playlist.value); } /** diff --git a/src/utils.ts b/src/utils.ts index 89815e0..ac3bfff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,5 @@ import type { GaxiosPromise } from "gaxios"; -import { Err, Ok, type Result } from "result4js"; +import { type Result, err, ok } from "neverthrow"; import { type YouTubesJsErrors, handleYouTubeApiError } from "./errors"; @@ -19,8 +19,8 @@ export async function wrapGaxios( ): Promise> { try { const response = await promise; - return Ok(response.data); + return ok(response.data); } catch (error) { - return Err(handleYouTubeApiError(error)); + return err(handleYouTubeApiError(error)); } } diff --git a/tests/playlist-item.test.ts b/tests/playlist-item.test.ts index 90771a5..01daa4b 100644 --- a/tests/playlist-item.test.ts +++ b/tests/playlist-item.test.ts @@ -208,6 +208,10 @@ test("playlistItemFrom", () => { for (const [input, expected] of dummy) { const actual = playlistItemFrom(input, logger); - expect(actual.data).toEqual(expected); + if (actual.isErr()) { + expect(actual.error).toEqual(expected); + } else { + expect(actual.value).toEqual(expected); + } } }); diff --git a/tests/playlist.test.ts b/tests/playlist.test.ts index 13d497d..61dd428 100644 --- a/tests/playlist.test.ts +++ b/tests/playlist.test.ts @@ -105,6 +105,11 @@ test("Playlist#from", () => { ]; for (const [data, expected] of dummy) { - expect(Playlist.from(data, logger).data).toEqual(expected); + const actual = Playlist.from(data, logger); + if (actual.isErr()) { + expect(actual.error).toEqual(expected); + } else { + expect(actual.value).toEqual(expected); + } } }); diff --git a/tests/privacy.test.ts b/tests/privacy.test.ts index c0df75e..8aee350 100644 --- a/tests/privacy.test.ts +++ b/tests/privacy.test.ts @@ -21,6 +21,11 @@ test("convertToPrivacy", () => { ]; for (const [data, expected] of dummy) { - expect(convertToPrivacy(data).data).toEqual(expected); + const actual = convertToPrivacy(data); + if (actual.isErr()) { + expect(actual.error).toEqual(expected); + } else { + expect(actual.value).toEqual(expected); + } } }); diff --git a/tests/thumbnails.test.ts b/tests/thumbnails.test.ts index a90b905..e573777 100644 --- a/tests/thumbnails.test.ts +++ b/tests/thumbnails.test.ts @@ -398,7 +398,12 @@ describe("Thumbnails", () => { test(`Thumbnails#from for ${dummy.length} cases`, () => { for (const [data, expected] of dummy) { - expect(Thumbnails.from(data, logger).data).toEqual(expected); + const actual = Thumbnails.from(data, logger); + if (actual.isErr()) { + expect(actual.error).toEqual(expected); + } else { + expect(actual.value).toEqual(expected); + } } }); });