Skip to content

Commit dba2721

Browse files
committed
feat: add exception
1 parent 799442c commit dba2721

File tree

3 files changed

+194
-0
lines changed

3 files changed

+194
-0
lines changed

src/__tests__/exception.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import type { Simplify } from 'type-fest';
2+
import { describe, expect, expectTypeOf, it, test } from 'vitest';
3+
4+
import { BaseException, ExceptionBuilder, type ExceptionConstructor, type ExceptionInstance } from '../exception.js';
5+
6+
type ExceptionOptionsWithCode = Simplify<ErrorOptions & { details: { code: number } }>;
7+
type ExceptionOptionsWithCause = Simplify<ErrorOptions & { cause: Error }>;
8+
type ExceptionOptionsWithCodeAndCause = Simplify<ExceptionOptionsWithCause & ExceptionOptionsWithCode>;
9+
10+
type ExceptionParams = { name: 'TestException' };
11+
type ExceptionParamsWithMessage = Simplify<ExceptionParams & { message: string }>;
12+
13+
test('ExceptionConstructor', () => {
14+
expectTypeOf<ExceptionConstructor<ExceptionParams, ErrorOptions>>().toEqualTypeOf<
15+
new (message?: string, options?: ErrorOptions) => ExceptionInstance<ExceptionParams, ErrorOptions>
16+
>();
17+
expectTypeOf<ExceptionConstructor<ExceptionParams, ExceptionOptionsWithCode>>().toEqualTypeOf<
18+
new (
19+
message: string,
20+
options: ExceptionOptionsWithCode
21+
) => ExceptionInstance<ExceptionParams, ExceptionOptionsWithCode>
22+
>();
23+
expectTypeOf<ExceptionConstructor<ExceptionParams, ExceptionOptionsWithCause>>().toEqualTypeOf<
24+
new (
25+
message: string,
26+
options: ExceptionOptionsWithCause
27+
) => ExceptionInstance<ExceptionParams, ExceptionOptionsWithCause>
28+
>();
29+
expectTypeOf<ExceptionConstructor<ExceptionParams, ExceptionOptionsWithCodeAndCause>>().toEqualTypeOf<
30+
new (
31+
message: string,
32+
options: ExceptionOptionsWithCodeAndCause
33+
) => ExceptionInstance<ExceptionParams, ExceptionOptionsWithCodeAndCause>
34+
>();
35+
expectTypeOf<ExceptionConstructor<ExceptionParamsWithMessage, ExceptionOptionsWithCodeAndCause>>().toEqualTypeOf<
36+
new (
37+
options: ExceptionOptionsWithCodeAndCause
38+
) => ExceptionInstance<ExceptionParamsWithMessage, ExceptionOptionsWithCodeAndCause>
39+
>();
40+
});
41+
42+
describe('BaseException', () => {
43+
it('should have parameters assignable to the base Error constructor by default', () => {
44+
expectTypeOf<ConstructorParameters<typeof BaseException>>().toMatchTypeOf<ConstructorParameters<typeof Error>>();
45+
});
46+
it('should allow explicit types for cause and details', () => {
47+
expectTypeOf<Pick<BaseException<any, { cause: Error }>, 'cause'>>().toEqualTypeOf<{ cause: Error }>();
48+
expectTypeOf<Pick<BaseException<any, { details: { code: number } }>, 'details'>>().toEqualTypeOf<{
49+
details: { code: number };
50+
}>();
51+
expectTypeOf<
52+
Pick<BaseException<any, { cause: Error; details: { code: number } }>, 'cause' | 'details'>
53+
>().toEqualTypeOf<{ cause: Error; details: { code: number } }>();
54+
});
55+
});
56+
57+
describe('ExceptionBuilder', () => {
58+
it('should return never for the build method if no name is specified', () => {
59+
const fn = () => new ExceptionBuilder().build();
60+
expect(fn).toThrow('Cannot build exception: params is undefined');
61+
expectTypeOf<ReturnType<typeof fn>>().toBeNever();
62+
});
63+
it('should build an exception with the provided name and message', () => {
64+
const TestException = new ExceptionBuilder().setParams({ name: 'TestException' }).build();
65+
expect(Object.getPrototypeOf(TestException)).toBe(BaseException);
66+
expectTypeOf<Pick<InstanceType<typeof TestException>, 'name'>>().toEqualTypeOf<{ name: 'TestException' }>();
67+
const error = new TestException('This is a test');
68+
expect(error).toBeInstanceOf(Error);
69+
expect(error.message).toBe('This is a test');
70+
expect(error.name).toBe('TestException');
71+
});
72+
73+
it('should create distinct constructors', () => {
74+
const TestException = new ExceptionBuilder().setParams({ name: 'TestException' }).build();
75+
const OtherException = new ExceptionBuilder().setParams({ name: 'OtherException' }).build();
76+
const e1 = new TestException('This is a test');
77+
const e2 = new OtherException('This is a test');
78+
expect(e1).toBeInstanceOf(BaseException);
79+
expect(e1).not.toBeInstanceOf(OtherException);
80+
expect(e2).toBeInstanceOf(BaseException);
81+
expect(e2).not.toBeInstanceOf(TestException);
82+
});
83+
84+
it('should allow creating an exception with additional details', () => {
85+
const TestException = new ExceptionBuilder()
86+
.setParams({ name: 'TestException' })
87+
.setOptionsType<{ details: { code: number } }>()
88+
.build();
89+
const error = new TestException('This is a test', { details: { code: 0 } });
90+
expect(error.details.code).toBe(0);
91+
expectTypeOf<ConstructorParameters<typeof TestException>>().toEqualTypeOf<
92+
[message: string, options: { details: { code: number } }]
93+
>();
94+
});
95+
96+
it('should allow creating an error with a custom cause', () => {
97+
const TestException = new ExceptionBuilder()
98+
.setParams({ name: 'TestException' })
99+
.setOptionsType<{ cause: Error }>()
100+
.build();
101+
const error = new TestException('This is a test', { cause: new Error('Test') });
102+
expect(error.cause.message).toBe('Test');
103+
expectTypeOf<ConstructorParameters<typeof TestException>>().toEqualTypeOf<
104+
[message: string, options: { cause: Error }]
105+
>();
106+
});
107+
108+
it('should allow creating an error with a default message', () => {
109+
const TestException = new ExceptionBuilder()
110+
.setParams({ message: 'Custom message', name: 'TestException' })
111+
.setOptionsType<{ cause: Error }>()
112+
.build();
113+
const error = new TestException({ cause: new Error('Test') });
114+
expect(error.message).toBe('Custom message');
115+
expectTypeOf<ConstructorParameters<typeof TestException>>().toEqualTypeOf<[options: { cause: Error }]>();
116+
});
117+
});

src/exception.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/* eslint-disable no-dupe-class-members */
2+
3+
import type { IsNever, RequiredKeysOf, Simplify } from 'type-fest';
4+
5+
type ExceptionOptions = Simplify<
6+
ErrorOptions & {
7+
details?: {
8+
[key: string]: unknown;
9+
};
10+
}
11+
>;
12+
13+
type ExceptionParams = {
14+
message?: string;
15+
name: string;
16+
};
17+
18+
export type ExceptionInstance<TParams extends ExceptionParams, TOptions extends ExceptionOptions> = Error & {
19+
cause: TOptions['cause'];
20+
details: TOptions['details'];
21+
name: TParams['name'];
22+
};
23+
24+
type ExceptionConstructorArgs<TParams extends ExceptionParams, TOptions extends ExceptionOptions> =
25+
IsNever<RequiredKeysOf<TOptions>> extends true
26+
? [message?: string, options?: TOptions]
27+
: TParams extends { message: string }
28+
? [TOptions]
29+
: [message: string, options: TOptions];
30+
31+
export type ExceptionConstructor<TParams extends ExceptionParams, TOptions extends ExceptionOptions> = new (
32+
...args: ExceptionConstructorArgs<TParams, TOptions>
33+
) => ExceptionInstance<TParams, TOptions>;
34+
35+
export abstract class BaseException<TParams extends ExceptionParams, TOptions extends ExceptionOptions>
36+
extends Error
37+
implements ExceptionInstance<TParams, TOptions>
38+
{
39+
public override cause: TOptions['cause'];
40+
public details: TOptions['details'];
41+
public abstract override name: TParams['name'];
42+
43+
constructor(message?: string, options?: TOptions) {
44+
super(message);
45+
this.cause = options?.cause;
46+
this.details = options?.details;
47+
}
48+
}
49+
50+
export class ExceptionBuilder<TParams extends ExceptionParams | undefined, TOptions extends ExceptionOptions> {
51+
params?: TParams;
52+
53+
build(): [TParams] extends [ExceptionParams] ? ExceptionConstructor<TParams, TOptions> : never;
54+
build(): ExceptionConstructor<NonNullable<TParams>, TOptions> | never {
55+
if (!this.params) {
56+
throw new Error('Cannot build exception: params is undefined');
57+
}
58+
const params = this.params;
59+
return class extends BaseException<NonNullable<TParams>, TOptions> {
60+
override name = params.name;
61+
constructor(...args: ExceptionConstructorArgs<NonNullable<TParams>, TOptions>) {
62+
const [message, options] = (params.message ? [params.message, args[0]] : args) as [string, TOptions];
63+
super(message, options);
64+
}
65+
};
66+
}
67+
68+
setOptionsType<TUpdatedOptions extends ExceptionOptions>() {
69+
return this as unknown as ExceptionBuilder<TParams, TUpdatedOptions>;
70+
}
71+
72+
setParams<const TUpdatedParams extends NonNullable<TParams>>(params: TUpdatedParams) {
73+
this.params = params;
74+
return this as unknown as ExceptionBuilder<TUpdatedParams, TOptions>;
75+
}
76+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from './array.js';
22
export * from './datetime.js';
3+
export * from './exception.js';
34
export * from './json.js';
45
export * from './number.js';
56
export * from './object.js';

0 commit comments

Comments
 (0)