Skip to content

Commit

Permalink
refactor: introduce ErrorWithKey (locales keys as error code) (#621)
Browse files Browse the repository at this point in the history
- Introduces following helpers:
   - `ErrorWithKey` (extends `Error`; useful for throwing with stack-trace)
   - `errorWithKey` (lighter version of `ErrorWithKey`; useful when stack-trace not needed)
   - `isErrorWithKey` (checks if an object is `ErrorWithKey` or response of `errorWithKey`)
- Extends `ErrorResponse` and `failure` to have optional `{error?: ErrorWithKeyLike}`
- Extends translation/localization helpers (`t`, `useTranslation`) to allow converting `ErrorWithKeyLike` to string
  • Loading branch information
sidvishnoi authored Sep 24, 2024
1 parent 9bb2bad commit 1447173
Show file tree
Hide file tree
Showing 4 changed files with 76 additions and 11 deletions.
5 changes: 5 additions & 0 deletions src/background/services/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
failure,
getNextOccurrence,
getWalletInformation,
isErrorWithKey,
success,
} from '@/shared/helpers';
import { OpenPaymentsClientError } from '@interledger/open-payments/dist/client/error';
Expand Down Expand Up @@ -250,6 +251,10 @@ export class Background {
return;
}
} catch (e) {
if (isErrorWithKey(e)) {
this.logger.error(message.action, e);
return failure({ key: e.key, substitutions: e.substitutions });
}
if (e instanceof OpenPaymentsClientError) {
this.logger.error(message.action, e.message, e.description);
return failure(
Expand Down
10 changes: 8 additions & 2 deletions src/popup/lib/context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import React, { type PropsWithChildren } from 'react';
import type { Browser } from 'webextension-polyfill';
import { tFactory, type Translation } from '@/shared/helpers';
import {
tFactory,
type ErrorWithKeyLike,
type Translation,
} from '@/shared/helpers';
import type { DeepNonNullable, PopupStore } from '@/shared/types';
import {
BACKGROUND_TO_POPUP_CONNECTION_NAME as CONNECTION_NAME,
Expand Down Expand Up @@ -153,7 +157,9 @@ export const BrowserContextProvider = ({
// #endregion

// #region Translation
const TranslationContext = React.createContext<Translation>((v: string) => v);
const TranslationContext = React.createContext<Translation>(
(v: string | ErrorWithKeyLike) => (typeof v === 'string' ? v : v.key),
);

export const useTranslation = () => React.useContext(TranslationContext);

Expand Down
70 changes: 61 additions & 9 deletions src/shared/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ import { parse, toSeconds } from 'iso8601-duration';
import type { Browser } from 'webextension-polyfill';
import type { Storage, RepeatingInterval, AmountValue } from './types';

export type TranslationKeys =
keyof typeof import('../_locales/en/messages.json');

export type ErrorKeys = Extract<TranslationKeys, `${string}_error_${string}`>;

export const cn = (...inputs: CxOptions) => {
return twMerge(cx(inputs));
};
Expand Down Expand Up @@ -53,16 +58,57 @@ export const getWalletInformation = async (
return json;
};

/**
* Error object with key and substitutions based on `_locales/[lang]/messages.json`
*/
export interface ErrorWithKeyLike<T extends ErrorKeys = ErrorKeys> {
key: Extract<ErrorKeys, T>;
// Could be empty, but required for checking if an object follows this interface
substitutions: string[];
}

export class ErrorWithKey<T extends ErrorKeys = ErrorKeys>
extends Error
implements ErrorWithKeyLike<T>
{
constructor(
public readonly key: ErrorWithKeyLike<T>['key'],
public readonly substitutions: ErrorWithKeyLike<T>['substitutions'] = [],
) {
super(key);
}
}

/**
* Same as {@linkcode ErrorWithKey} but creates plain object instead of Error
* instance.
* Easier than creating object ourselves, but more performant than Error.
*/
export const errorWithKey = <T extends ErrorKeys = ErrorKeys>(
key: ErrorWithKeyLike<T>['key'],
substitutions: ErrorWithKeyLike<T>['substitutions'] = [],
) => ({ key, substitutions });

export const isErrorWithKey = (err: any): err is ErrorWithKeyLike => {
if (!err || typeof err !== 'object') return false;
return (
err instanceof ErrorWithKey ||
(typeof err.key === 'string' && Array.isArray(err.substitutions))
);
};

export const success = <TPayload = undefined>(
payload: TPayload,
): SuccessResponse<TPayload> => ({
success: true,
payload,
});

export const failure = (message: string) => ({
success: false,
message,
export const failure = (message: string | ErrorWithKeyLike) => ({
success: false as const,
...(typeof message === 'string'
? { message }
: { error: message, message: message.key }),
});

export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
Expand Down Expand Up @@ -199,19 +245,25 @@ export function bigIntMax<T extends bigint | AmountValue>(a: T, b: T): T {
return BigInt(a) > BigInt(b) ? a : b;
}

export type TranslationKeys =
keyof typeof import('../_locales/en/messages.json');

export type Translation = ReturnType<typeof tFactory>;
export function tFactory(browser: Pick<Browser, 'i18n'>) {
/**
* Helper over calling cumbersome `this.browser.i18n.getMessage(key)` with
* added benefit that it type-checks if key exists in message.json
*/
return <T extends TranslationKeys>(
function t<T extends TranslationKeys>(
key: T,
substitutions?: string | string[],
) => browser.i18n.getMessage(key, substitutions);
substitutions?: string[],
): string;
function t<T extends ErrorKeys>(err: ErrorWithKeyLike<T>): string;
function t(key: string | ErrorWithKeyLike, substitutions?: string[]): string {
if (typeof key === 'string') {
return browser.i18n.getMessage(key, substitutions);
}
const err = key;
return browser.i18n.getMessage(err.key, err.substitutions);
}
return t;
}

type Primitive = string | number | boolean | null | undefined;
Expand Down
2 changes: 2 additions & 0 deletions src/shared/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
} from '@interledger/open-payments';
import type { Browser } from 'webextension-polyfill';
import type { AmountValue, Storage } from '@/shared/types';
import type { ErrorWithKeyLike } from '@/shared/helpers';
import type { PopupState } from '@/popup/lib/context';

// #region MessageManager
Expand All @@ -15,6 +16,7 @@ export interface SuccessResponse<TPayload = void> {
export interface ErrorResponse {
success: false;
message: string;
error?: ErrorWithKeyLike;
}

export type Response<TPayload = void> =
Expand Down

0 comments on commit 1447173

Please sign in to comment.