diff --git a/packages/0/src/composables/createContext/index.test.ts b/packages/0/src/composables/createContext/index.test.ts index 72f25f5c4..63c66a6a4 100644 --- a/packages/0/src/composables/createContext/index.test.ts +++ b/packages/0/src/composables/createContext/index.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createContext, provideContext, useContext } from './index' // Utilities +import { isV0Error, V0Error } from '#v0/utilities' import { inject, provide } from 'vue' // Types @@ -69,6 +70,25 @@ describe('createContext', () => { ) }) + it('should throw a V0Error tagged V0_CONTEXT_MISSING with the key payload', () => { + mockInject.mockReturnValue(undefined) + + const [injectContext] = createContext('v0:missing-key') + + let caught: unknown + try { + injectContext() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(V0Error) + expect(isV0Error(caught, 'V0_CONTEXT_MISSING')).toBe(true) + if (isV0Error(caught, 'V0_CONTEXT_MISSING')) { + expect(caught.code).toBe('V0_CONTEXT_MISSING') + expect(caught.key).toBe('v0:missing-key') + } + }) + it('should handle symbol key in error message', () => { const symbolKey = Symbol('symbol-key') mockInject.mockReturnValue(undefined) diff --git a/packages/0/src/composables/createContext/index.ts b/packages/0/src/composables/createContext/index.ts index 75e6935bb..bcb3ed706 100644 --- a/packages/0/src/composables/createContext/index.ts +++ b/packages/0/src/composables/createContext/index.ts @@ -26,7 +26,7 @@ */ // Utilities -import { isObject, isString, isSymbol, isUndefined } from '#v0/utilities' +import { isObject, isString, isSymbol, isUndefined, V0Error } from '#v0/utilities' import { inject, provide } from 'vue' // Types @@ -64,7 +64,10 @@ export function useContext (key: ContextKey, defaultValue?: Z) { const context = inject(key, defaultValue as Z) if (isUndefined(context)) { - throw new Error(`Context "${String(key)}" not found. Ensure it's provided by an ancestor.`) + throw new V0Error(`Context "${String(key)}" not found. Ensure it's provided by an ancestor.`, { + code: 'V0_CONTEXT_MISSING', + key, + }) } return context diff --git a/packages/0/src/composables/useDate/index.test.ts b/packages/0/src/composables/useDate/index.test.ts index 3b3542596..3b8534953 100644 --- a/packages/0/src/composables/useDate/index.test.ts +++ b/packages/0/src/composables/useDate/index.test.ts @@ -6,6 +6,9 @@ import { V0DateAdapter } from './adapters/v0' import { createDate, createDateContext, createDatePlugin, useDate } from './index' +// Utilities +import { isV0Error, V0Error } from '#v0/utilities' + describe('createDate', () => { describe('v0DateAdapter', () => { let adapter: V0DateAdapter @@ -1592,6 +1595,21 @@ describe('createDate', () => { it('should throw with helpful error message', () => { expect(() => useDate()).toThrow('createDatePlugin') }) + + it('should throw a V0Error tagged V0_PLUGIN_MISSING with the plugin payload', () => { + let caught: unknown + try { + useDate() + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(V0Error) + expect(isV0Error(caught, 'V0_PLUGIN_MISSING')).toBe(true) + if (isV0Error(caught, 'V0_PLUGIN_MISSING')) { + expect(caught.code).toBe('V0_PLUGIN_MISSING') + expect(caught.plugin).toBe('createDatePlugin') + } + }) }) describe('component lifecycle integration', () => { diff --git a/packages/0/src/composables/useDate/index.ts b/packages/0/src/composables/useDate/index.ts index fa077bb96..e17b98d95 100644 --- a/packages/0/src/composables/useDate/index.ts +++ b/packages/0/src/composables/useDate/index.ts @@ -45,7 +45,7 @@ import { createTrinity } from '#v0/composables/createTrinity' import { useLocale } from '#v0/composables/useLocale' // Utilities -import { instanceExists, isNullOrUndefined, isUndefined } from '#v0/utilities' +import { instanceExists, isNullOrUndefined, isUndefined, V0Error } from '#v0/utilities' import { computed, watchEffect, onScopeDispose } from 'vue' // Types @@ -329,12 +329,16 @@ export function useDate< E extends DateContext = DateContext, > (namespace = 'v0:date'): E { if (!instanceExists()) { - throw new Error( + throw new V0Error( '[v0] useDate() must be called inside a Vue component with createDatePlugin installed.\n\n' + 'Example:\n' + ' import { V0DateAdapter } from \'@vuetify/v0/date\'\n' + ' import { createDatePlugin } from \'@vuetify/v0\'\n\n' + ' app.use(createDatePlugin({ adapter: new V0DateAdapter() }))', + { + code: 'V0_PLUGIN_MISSING', + plugin: 'createDatePlugin', + }, ) } diff --git a/packages/0/src/composables/useLogger/adapters/consola.ts b/packages/0/src/composables/useLogger/adapters/consola.ts index cc668a77e..6827db097 100644 --- a/packages/0/src/composables/useLogger/adapters/consola.ts +++ b/packages/0/src/composables/useLogger/adapters/consola.ts @@ -1,13 +1,19 @@ // Adapters import { LoggerAdapter } from './adapter' +// Utilities +import { V0Error } from '#v0/utilities' + export class ConsolaLoggerAdapter extends LoggerAdapter { private consola: LoggerAdapter constructor (consolaInstance: LoggerAdapter | null | undefined) { super() if (!consolaInstance) { - throw new Error('Consola instance is required for ConsolaLoggerAdapter') + throw new V0Error('Consola instance is required for ConsolaLoggerAdapter', { + code: 'V0_ADAPTER_INSTANCE_MISSING', + adapter: 'ConsolaLoggerAdapter', + }) } this.consola = consolaInstance } diff --git a/packages/0/src/composables/useLogger/adapters/pino.ts b/packages/0/src/composables/useLogger/adapters/pino.ts index 7141a303d..f18d478da 100644 --- a/packages/0/src/composables/useLogger/adapters/pino.ts +++ b/packages/0/src/composables/useLogger/adapters/pino.ts @@ -2,7 +2,7 @@ import { LoggerAdapter } from './adapter' // Utilities -import { isObject } from '#v0/utilities' +import { isObject, V0Error } from '#v0/utilities' /** * Pino logger adapter implementation @@ -26,7 +26,10 @@ export class PinoLoggerAdapter extends LoggerAdapter { constructor (pinoInstance: PinoInstance | null | undefined) { super() if (!pinoInstance) { - throw new Error('Pino instance is required for PinoLoggerAdapter') + throw new V0Error('Pino instance is required for PinoLoggerAdapter', { + code: 'V0_ADAPTER_INSTANCE_MISSING', + adapter: 'PinoLoggerAdapter', + }) } this.pino = pinoInstance } diff --git a/packages/0/src/index.ts b/packages/0/src/index.ts index fdb445c93..249a31779 100644 --- a/packages/0/src/index.ts +++ b/packages/0/src/index.ts @@ -1,5 +1,5 @@ export * from './components' export * from './composables' export * from './constants' -export type { ID } from './types' +export type { ID, V0ErrorCode, V0ErrorDetails } from './types' export * from './utilities' diff --git a/packages/0/src/palettes/ant/generate/index.ts b/packages/0/src/palettes/ant/generate/index.ts index 22fb0e28a..39e24dc15 100644 --- a/packages/0/src/palettes/ant/generate/index.ts +++ b/packages/0/src/palettes/ant/generate/index.ts @@ -1,5 +1,8 @@ import { generate } from '@ant-design/colors' +// Utilities +import { V0Error } from '#v0/utilities' + // Types import type { PaletteDefinition } from '#v0/palettes' @@ -30,7 +33,11 @@ function contrast (hex: string): string { /* #__NO_SIDE_EFFECTS__ */ export function ant (seed: string, options: AntGenerateOptions = {}): PaletteDefinition { if (!HEX_RE.test(seed)) { - throw new Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#1677ff").`) + throw new V0Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#1677ff").`, { + code: 'V0_PALETTE_INVALID_SEED', + palette: 'ant', + seed, + }) } const { background = '#141414' } = options diff --git a/packages/0/src/palettes/leonardo/generate/index.ts b/packages/0/src/palettes/leonardo/generate/index.ts index 87d1d1aa8..1fa1f1903 100644 --- a/packages/0/src/palettes/leonardo/generate/index.ts +++ b/packages/0/src/palettes/leonardo/generate/index.ts @@ -1,7 +1,7 @@ import { Color, Theme } from '@adobe/leonardo-contrast-colors' // Utilities -import { isArray, isObject } from '#v0/utilities' +import { isArray, isObject, V0Error } from '#v0/utilities' // Types import type { PaletteDefinition } from '#v0/palettes' @@ -77,7 +77,11 @@ function mapSemanticColors (colors: Record, ratios: number[]): R /* #__NO_SIDE_EFFECTS__ */ export function leonardo (seed: string, options: LeonardoGenerateOptions = {}): PaletteDefinition { if (!HEX_RE.test(seed)) { - throw new Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#0ea5e9").`) + throw new V0Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#0ea5e9").`, { + code: 'V0_PALETTE_INVALID_SEED', + palette: 'leonardo', + seed, + }) } const { ratios = DEFAULT_RATIOS, colorSpace = 'OKLCH' } = options diff --git a/packages/0/src/palettes/material/generate/index.ts b/packages/0/src/palettes/material/generate/index.ts index d684cd03a..10d30cf1d 100644 --- a/packages/0/src/palettes/material/generate/index.ts +++ b/packages/0/src/palettes/material/generate/index.ts @@ -10,6 +10,9 @@ import { hexFromArgb, } from '@material/material-color-utilities' +// Utilities +import { V0Error } from '#v0/utilities' + // Types import type { PaletteDefinition } from '#v0/palettes' import type { TonalPalette } from '@material/material-color-utilities' @@ -74,7 +77,11 @@ function extractSchemeColors (scheme: InstanceType): Rec /* #__NO_SIDE_EFFECTS__ */ export function material (seed: string, options: MaterialGenerateOptions = {}): PaletteDefinition { if (!HEX_RE.test(seed)) { - throw new Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#6750A4").`) + throw new V0Error(`[@vuetify/v0] Invalid seed color: "${seed}". Expected a hex string (e.g., "#6750A4").`, { + code: 'V0_PALETTE_INVALID_SEED', + palette: 'material', + seed, + }) } const { variant = 'tonalSpot', contrast = 0 } = options @@ -83,7 +90,11 @@ export function material (seed: string, options: MaterialGenerateOptions = {}): const SchemeClass = VARIANTS[variant] if (!SchemeClass) { - throw new Error(`[@vuetify/v0] Unknown material variant: "${variant}"`) + throw new V0Error(`[@vuetify/v0] Unknown material variant: "${variant}"`, { + code: 'V0_PALETTE_UNKNOWN_VARIANT', + palette: 'material', + variant, + }) } const light = new SchemeClass(hct, false, contrast) diff --git a/packages/0/src/types/index.ts b/packages/0/src/types/index.ts index 20b60c7db..eaea818fd 100644 --- a/packages/0/src/types/index.ts +++ b/packages/0/src/types/index.ts @@ -135,3 +135,52 @@ export type Extensible = T | (string & {}) * ``` */ export type Activation = 'automatic' | 'manual' + +/** + * Discriminated union of structured details attached to every v0-thrown error + * + * @remarks + * Each arm pairs a stable `code` discriminant with the domain context for that + * code. Consumed by the `V0Error` constructor in `#v0/utilities` — the union + * is the source of truth for both what codes exist and what payload each code + * carries. + * + * Prefer `isV0Error(err, code)` over manual narrowing — see + * `V0Error` and `isV0Error` in `#v0/utilities` for the consumer-facing API + * and worked examples. + * + * Inspiration: tRPC's `TRPCError.code`, Node's `error.code` registry. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + * @see https://nodejs.org/api/errors.html#nodejs-error-codes + * @see https://trpc.io/docs/server/error-handling + * + * @example + * ```ts + * const details: V0ErrorDetails = { code: 'V0_CONTEXT_MISSING', key: 'theme' } + * ``` + */ +export type V0ErrorDetails = + | { code: 'V0_CONTEXT_MISSING', key: string | symbol } + | { code: 'V0_PLUGIN_MISSING', plugin: string } + | { code: 'V0_PALETTE_INVALID_SEED', palette: 'material' | 'leonardo' | 'ant', seed: string } + | { code: 'V0_PALETTE_UNKNOWN_VARIANT', palette: 'material', variant: string } + | { code: 'V0_ADAPTER_INSTANCE_MISSING', adapter: string } + +/** + * Union of every error code thrown by v0 + * + * @remarks + * Convenience alias for the discriminant field of {@link V0ErrorDetails}. + * + * @example + * ```ts + * function describe (code: V0ErrorCode): string { + * switch (code) { + * case 'V0_CONTEXT_MISSING': return 'missing provider' + * case 'V0_PLUGIN_MISSING': return 'plugin not installed' + * } + * } + * ``` + */ +export type V0ErrorCode = V0ErrorDetails['code'] diff --git a/packages/0/src/utilities/errors.ts b/packages/0/src/utilities/errors.ts new file mode 100644 index 000000000..ca7cd387f --- /dev/null +++ b/packages/0/src/utilities/errors.ts @@ -0,0 +1,124 @@ +/** + * @module utilities/errors + * + * @remarks + * Structured error class thrown by v0 internals. Pattern modeled on + * tRPC's `TRPCError` and Node's built-in `Error` codes — `code` is a + * top-level discriminant field on the error, *not* a payload nested + * inside `Error.cause`. `cause` stays reserved for wrapping the + * genuine upstream error so error trackers (Sentry's `LinkedErrors`, + * Datadog RUM, Rollbar) render the chain correctly. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause + * @see https://nodejs.org/api/errors.html#nodejs-error-codes + * @see https://trpc.io/docs/server/error-handling + * @see https://docs.sentry.io/platforms/javascript/configuration/integrations/linkederrors/ + */ + +// Utilities +import { isUndefined } from './helpers' + +// Types +import type { V0ErrorCode, V0ErrorDetails } from '#v0/types' + +/** + * Structured error thrown by v0 internals. + * + * @remarks + * Carries a stable {@link V0ErrorCode} discriminant on `code` so + * consumers, devtools panels, and error trackers can identify the + * error without parsing the message string. Domain context lives in + * top-level fields per code (`key` for `V0_CONTEXT_MISSING`, `plugin` + * for `V0_PLUGIN_MISSING`), populated from a {@link V0ErrorDetails} + * argument and merged onto the instance at construction time. + * + * `cause` is forwarded to `Error`'s standard `ErrorOptions.cause` slot + * and is intended to carry a wrapped upstream error, not metadata. + * + * Prefer {@link isV0Error} over manual `instanceof` checks at the + * narrowing site — the guard intersects the instance type with the + * matching {@link V0ErrorDetails} arm, so per-code fields are typed. + * + * @example Author site + * ```ts + * throw new V0Error('Context not found.', { code: 'V0_CONTEXT_MISSING', key }) + * ``` + * + * @example Author site with upstream wrap + * ```ts + * try { + * JSON.parse(raw) + * } catch (err) { + * throw new V0Error('Failed to hydrate registry.', { + * code: 'V0_CONTEXT_MISSING', + * key: 'v0:registry', + * cause: err, + * }) + * } + * ``` + * + * @example Consumer site + * ```ts + * try { + * useContext(myKey) + * } catch (err) { + * if (isV0Error(err, 'V0_CONTEXT_MISSING')) { + * console.log(err.key) + * } + * } + * ``` + */ +export class V0Error extends Error { + override name = 'V0Error' + readonly code: V0ErrorCode + readonly key?: string | symbol + readonly plugin?: string + readonly palette?: string + readonly seed?: string + readonly variant?: string + readonly adapter?: string + + constructor (message: string, details: V0ErrorDetails & { cause?: unknown }) { + const { cause, ...rest } = details + super(message, isUndefined(cause) ? undefined : { cause }) + this.code = details.code + Object.assign(this, rest) + } +} + +/* #__NO_SIDE_EFFECTS__ */ +/** + * Type guard for v0-thrown errors. + * + * @param value The value to test. + * @param code Optional discriminant — narrow the result to a specific + * {@link V0ErrorCode} so per-code fields become required. + * + * @remarks + * Without the optional `code` argument, the guard verifies the value + * is a {@link V0Error} instance. With the `code` argument, the guard + * also narrows the instance type to the intersection with the matching + * {@link V0ErrorDetails} arm — fields like `key` or `plugin` change + * from optional to required on the narrowed type. + * + * @example Narrow to any v0 error + * ```ts + * if (isV0Error(err)) { + * console.log(err.code) + * } + * ``` + * + * @example Narrow to a specific code + * ```ts + * if (isV0Error(err, 'V0_CONTEXT_MISSING')) { + * console.log(err.key) // typed as string | symbol, not string | symbol | undefined + * } + * ``` + */ +export function isV0Error ( + value: unknown, + code?: C, +): value is V0Error & Extract { + if (!(value instanceof V0Error)) return false + return isUndefined(code) || value.code === code +} diff --git a/packages/0/src/utilities/index.ts b/packages/0/src/utilities/index.ts index 691325936..1bbad6154 100644 --- a/packages/0/src/utilities/index.ts +++ b/packages/0/src/utilities/index.ts @@ -1,5 +1,6 @@ // Utilities export * from './apca' export * from './color' +export * from './errors' export * from './helpers' export * from './instance'