DeesseJS Errors

Multiple Inheritance

Create errors that inherit from multiple parent error types in @deessejs/errors.

While single inheritance creates clear parent-child relationships, some errors naturally belong to multiple categories. @deessejs/errors supports multiple inheritance, allowing an error to inherit from several parent types simultaneously.

Basic Multiple Inheritance

To inherit from multiple parents, pass an array of error factories to the inherits property:

basic.ts
import { error, is } from '@deessejs/errors';

const NetworkError = error({ name: 'NetworkError' });
const StorageError = error({ name: 'StorageError' });

// Inherits from both NetworkError and StorageError
const NetworkStorageError = error({
  name: 'NetworkStorageError',
  inherits: [NetworkError, StorageError],
});

Now this error belongs to both categories in the hierarchy.

Type Checking with Multiple Parents

When an error inherits from multiple parents, is() returns true for any of its parents:

checking.ts
import { error, is } from '@deessejs/errors';

const NetworkError = error({ name: 'NetworkError' });
const StorageError = error({ name: 'StorageError' });

const NetworkStorageError = error({
  name: 'NetworkStorageError',
  inherits: [NetworkError, StorageError],
});

// The no-schema factories have an empty input shape; the call
// takes no arguments.
const err = NetworkStorageError();

console.log(is(err, NetworkStorageError)); // -> true
console.log(is(err, NetworkError)); // -> true
console.log(is(err, StorageError)); // -> true

This flexibility lets you categorize errors from multiple angles.

Practical Example

Consider a caching layer that sits between the network and storage. It might fail in ways that are both network-related and storage-related:

example.ts
import { error, raise, is } from '@deessejs/errors';

const NetworkError = error({ name: 'NetworkError' });
const StorageError = error({ name: 'StorageError' });

const CacheError = error({
  name: 'CacheError',
  message: 'Cache operation failed',
  inherits: [NetworkError, StorageError],
});

// Handler that deals with network issues
function handleNetworkIssue(err: unknown) {
  if (is(err, NetworkError)) {
    console.log('Network problem detected:', err.message);
  }
}

// Handler that deals with storage issues
function handleStorageIssue(err: unknown) {
  if (is(err, StorageError)) {
    console.log('Storage problem detected:', err.message);
  }
}

const cacheErr = CacheError();

handleNetworkIssue(cacheErr); // Logs (through NetworkError inheritance)
handleStorageIssue(cacheErr); // Logs (through StorageError inheritance)

This pattern is useful for middleware and infrastructure code that needs to respond to errors based on their characteristics rather than their specific type.

Combining with Specific Error Data

Multiple inheritance works with schema-bearing factories. Each parent declares its own schema; the leaf composes a schema whose output is assignable to every parent's output:

with-fields.ts
import { z } from 'zod';
import { error, is } from '@deessejs/errors';

const NetworkError = error({
  name: 'NetworkError',
  fields: z.object({ endpoint: z.string() }),
  message: (d) => `Network request to ${d.endpoint} failed`,
});

const StorageError = error({
  name: 'StorageError',
  fields: z.object({ path: z.string() }),
  message: (d) => `Storage operation at ${d.path} failed`,
});

// The leaf's output must be assignable to every parent's output.
// The leaf's `fields` carries its own specific data, plus the
// keys the parents require.
const CacheError = error({
  name: 'CacheError',
  fields: z.object({
    endpoint: z.string(),
    path: z.string(),
    key: z.string(),
    operation: z.string(),
  }),
  message: (d) =>
    `Cache ${d.operation} for key ${d.key} at ${d.endpoint} → ${d.path} failed`,
  inherits: [NetworkError, StorageError],
});

const err = CacheError({
  endpoint: '/api',
  path: '/var/cache',
  key: 'user:123',
  operation: 'read',
});
console.log(err.fields.key); // "user:123"
console.log(err.message); // "Cache read for key user:123 at /api → /var/cache failed"

// The leaf is also recognized as an instance of every parent.
console.log(is(err, NetworkError)); // true
console.log(is(err, StorageError)); // true

When to Use Multiple Inheritance

Multiple inheritance is appropriate when:

The error has multiple dimensions — A timeout could be a network issue and a service issue simultaneously.

You're building middleware — Libraries between the application and infrastructure often need to respond to multiple error categories.

You want flexible handling — Code can catch errors by any of their characteristics, not just their exact type.

Avoid using multiple inheritance just because you can. Prefer single inheritance for most errors, and only use multiple inheritance when the error genuinely belongs to multiple categories.

Schema Validation Across Multiple Parents

Multiple inheritance declares a static type-level relationship: the leaf's InferOutput must be assignable to every parent's InferOutput. The constraint is checked at the error() definition site — a violation is a TypeScript error, not a runtime throw. Each factory still runs its own schema; the parents' schemas do not validate the child's data.

composition.ts
import { z } from 'zod';
import { error } from '@deessejs/errors';

const NetworkError = error({
  name: 'NetworkError',
  fields: z.object({ endpoint: z.string() }),
  message: (d) => d.endpoint,
});
const StorageError = error({
  name: 'StorageError',
  fields: z.object({ path: z.string() }),
  message: (d) => d.path,
});

// The consumer composes the leaf's schema to satisfy both
// parents. TypeScript verifies that the leaf's output is
// assignable to NetworkError's AND StorageError's.
const CacheError = error({
  name: 'CacheError',
  fields: z.object({ endpoint: z.string(), path: z.string() }),
  message: (d) => `${d.endpoint} → ${d.path}`,
  inherits: [NetworkError, StorageError],
});

If the leaf's output is not assignable to one of the parents, the call site errors:

sibling-incompatible.ts
import { z } from 'zod';
import { error } from '@deessejs/errors';

const A = error({
  name: 'A',
  fields: z.object({ a: z.string() }),
  message: (d) => d.a,
});
const B = error({
  name: 'B',
  fields: z.object({ b: z.number() }),
  message: (d) => String(d.b),
});

// Type error: the leaf is missing `b` for B.
error({
  name: 'C',
  fields: z.object({ a: z.string() }),
  message: (d) => d.a,
  // @ts-expect-error — child is missing b for B
  inherits: [A, B],
});

Diamond inheritance (A ← B, A ← C, D.inherits: [B, C]) is recognized for is() walks but does not cause any cascade. The leaf's schema is the only one that runs.

See Also

On this page