Skip to content

Commit 84feec7

Browse files
authored
refactor: enables type-safe exception handling (#204)
- replaces all `try` statements with usage of `Attempt` utility and `Outcome` return type - helps prevent try-catch pyramids of doom - enforces usage with linter-driven syntax restriction
1 parent a1e39fe commit 84feec7

File tree

7 files changed

+273
-21
lines changed

7 files changed

+273
-21
lines changed

eslint.config.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,22 @@ const configWithVueTS = defineConfigWithVueTs(
8787

8888
{
8989
rules: {
90-
'eqeqeq' : 'error', // Avoids `==` and `!=`, which perform type coercions that follow the rather obscure Abstract Equality Comparison Algorithm: https://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3
91-
'no-implicit-coercion' : 'error', // Using constructors, factories, and parsers for coercion rather than operators leads to less confusing behavior
92-
'@typescript-eslint/explicit-member-accessibility': 'error', // Easier to see dead code in situations where a member is marked `private`
90+
'eqeqeq': [
91+
'error', // Avoids `==` and `!=`, which perform type coercions that follow the rather obscure Abstract Equality Comparison Algorithm: https://www.ecma-international.org/ecma-262/5.1/#sec-11.9.3
92+
],
93+
'no-implicit-coercion': [
94+
'error', // Using constructors, factories, and parsers for coercion rather than operators leads to less confusing behavior
95+
],
96+
'no-restricted-syntax': [
97+
'error',
98+
{
99+
selector: 'TryStatement',
100+
message : 'Prefer `Attempt` over `try`/`catch` for error handling.',
101+
},
102+
],
103+
'@typescript-eslint/explicit-member-accessibility': [
104+
'error', // Easier to see dead code in situations where a member is marked `private`
105+
],
93106
},
94107
},
95108

src/features/TextToImage/models/SDXL/client/index.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,13 @@ import type {
22
GenerateFromTextRequest,
33
} from 'stabilityai-client-typescript/models/operations';
44

5+
import Attempt from '@/library/Attempt';
56
import ShimmedStabilityAIClient from '@/library/ShimmedStabilityAIClient/index.ts';
67

78
import Base64CharacterEncodedByteSequence from '@/library/customTypes/Base64CharacterEncodedByteSequence.ts';
89

910
import ImageURI from '@/library/customTypes/UniformResourceIdentifier/Data/Image/index.ts';
1011

11-
import {
12-
asError,
13-
} from '@/library/utilitiesByType/error';
14-
1512
import type {
1613
Output,
1714
} from '@/features/TextToImage/types';
@@ -53,18 +50,15 @@ export const forciblyGenerateOutputFrom = async (
5350
},
5451
});
5552

56-
let textToImageResponse: Awaited<typeof promisedTextToImageResponse>;
53+
const outcomeOfSettlingTextToImageResponse = await Attempt.toSettle(promisedTextToImageResponse);
5754

58-
try {
59-
textToImageResponse = await promisedTextToImageResponse;
60-
}
61-
catch (someException) {
62-
const someError = asError(someException);
63-
const clientFailedToReachServer = someError.message.includes('Failed to fetch');
55+
if (outcomeOfSettlingTextToImageResponse.isFailure) {
56+
const errorThatPreventedResponse = outcomeOfSettlingTextToImageResponse.causeOfFailure;
57+
const clientFailedToReachServer = errorThatPreventedResponse.message.includes('Failed to fetch');
6458

6559
if (
6660
!clientFailedToReachServer
67-
) throw someError;
61+
) throw errorThatPreventedResponse;
6862

6963
const serverConnectionException = [
7064
'Failed to reach the text-to-image server.',
@@ -74,6 +68,8 @@ export const forciblyGenerateOutputFrom = async (
7468
throw new Error(serverConnectionException);
7569
}
7670

71+
const textToImageResponse = outcomeOfSettlingTextToImageResponse.productOfSuccess;
72+
7773
if (
7874
!('artifacts' in textToImageResponse.result)
7975
) throw new Error('Expected response rather than readable stream');

src/library/Attempt/Outcome.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import type {
2+
Is,
3+
Not,
4+
} from '@/library/typeUtilities/Boolean';
5+
import type {
6+
Filter,
7+
} from '@/library/typeUtilities/Filter';
8+
9+
// cspell:words sugarfree
10+
interface SyntacticallySugarfreeEmptyOutcome {
11+
readonly case: 'success' | 'failure';
12+
}
13+
14+
interface EmptyOutcome extends SyntacticallySugarfreeEmptyOutcome {
15+
readonly isSuccess: Is<this['case'], 'success'>;
16+
readonly isFailure: Not<this['isSuccess']>;
17+
}
18+
19+
interface SemanticallySugarfreeSuccess<SomeProduct> extends EmptyOutcome {
20+
readonly case: 'success';
21+
readonly product: SomeProduct;
22+
}
23+
24+
interface SemanticallySugarfreeFailure extends EmptyOutcome {
25+
readonly case: 'failure';
26+
readonly cause: Error;
27+
}
28+
29+
interface Success<SomeProduct> extends SemanticallySugarfreeSuccess<SomeProduct> {
30+
/**
31+
* Semantic sugar for `product`; useful for juxtaposition against guard statements:
32+
* ```ts
33+
* ...
34+
*
35+
* if (
36+
* someOutcome.isFailure
37+
* ) return null;
38+
*
39+
* return someOutcome.productOfSuccess;
40+
* ```
41+
*/
42+
readonly productOfSuccess: this['product'];
43+
}
44+
45+
interface Failure extends SemanticallySugarfreeFailure {
46+
/**
47+
* Semantic sugar for `cause`; useful for juxtaposition against guard statements:
48+
* ```ts
49+
* ...
50+
*
51+
* if (
52+
* someOutcome.isSuccess
53+
* ) return;
54+
*
55+
* throw someOutcome.causeOfFailure;
56+
* ```
57+
*/
58+
readonly causeOfFailure: this['cause'];
59+
}
60+
61+
type Outcome<
62+
SomeProduct,
63+
> =
64+
| Success<SomeProduct>
65+
| Failure;
66+
67+
type Sugarfree<
68+
SomeOutcome extends Outcome<unknown>,
69+
> = Filter<SomeOutcome,
70+
| 'case'
71+
| 'product'
72+
| 'cause'
73+
>;
74+
75+
const sugarfreeFailureDueTo = <
76+
SomeProduct,
77+
>(
78+
givenError: Error,
79+
): Sugarfree<Outcome<SomeProduct>> => ({
80+
case : 'failure',
81+
cause: givenError,
82+
});
83+
84+
const sugarfreeSuccessThatYielded = <
85+
SomeProduct,
86+
>(
87+
givenProduct: SomeProduct,
88+
): Sugarfree<Outcome<SomeProduct>> => ({
89+
case : 'success',
90+
product: givenProduct,
91+
});
92+
93+
const withSugar = <
94+
SomeProduct,
95+
>(
96+
given: Sugarfree<Outcome<SomeProduct>>,
97+
): Outcome<SomeProduct> => {
98+
switch (given.case) {
99+
case 'success': return {
100+
...given,
101+
isSuccess : true,
102+
isFailure : false,
103+
productOfSuccess: given.product,
104+
};
105+
case 'failure': return {
106+
...given,
107+
isSuccess : false,
108+
isFailure : true,
109+
causeOfFailure: given.cause,
110+
};
111+
}
112+
};
113+
114+
const failureDueTo = <
115+
SomeProduct,
116+
>(
117+
givenError: Error,
118+
): Outcome<SomeProduct> => {
119+
return withSugar<SomeProduct>(
120+
sugarfreeFailureDueTo(givenError),
121+
);
122+
};
123+
124+
const successThatYielded = <
125+
SomeProduct,
126+
>(
127+
givenProduct: SomeProduct,
128+
): Outcome<SomeProduct> => {
129+
return withSugar(
130+
sugarfreeSuccessThatYielded(givenProduct),
131+
);
132+
};
133+
134+
export const Success = {
135+
thatYielded: successThatYielded,
136+
};
137+
138+
export const Failure = {
139+
dueTo: failureDueTo,
140+
};
141+
142+
const Outcome = {
143+
Negative: Failure,
144+
Positive: Success,
145+
};
146+
147+
export default Outcome;

src/library/Attempt/index.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import {
2+
asError,
3+
} from '@/library/utilitiesByType/error';
4+
5+
import Outcome, {
6+
Failure,
7+
Success,
8+
} from './Outcome';
9+
10+
const attemptTo = <
11+
SomeProduct,
12+
>(
13+
getProductFromSomeProcessThatCanThrow: () => SomeProduct,
14+
): Outcome<SomeProduct> => {
15+
// eslint-disable-next-line no-restricted-syntax
16+
try {
17+
const productFromSomeProcessThatDidNotThrow = getProductFromSomeProcessThatCanThrow();
18+
return Success.thatYielded(productFromSomeProcessThatDidNotThrow);
19+
}
20+
catch (someException) {
21+
const someError = asError(someException);
22+
return Failure.dueTo(someError);
23+
}
24+
};
25+
26+
const attemptToOpaquely = <
27+
SomeProduct,
28+
>(
29+
getProductFromSomeProcessThatCanThrow: () => SomeProduct,
30+
): SomeProduct | null => {
31+
const outcomeOfProcess = attemptTo(getProductFromSomeProcessThatCanThrow);
32+
33+
if (
34+
outcomeOfProcess.isFailure
35+
) return null;
36+
37+
return outcomeOfProcess.productOfSuccess;
38+
};
39+
40+
const attemptToEventually = async <
41+
SomeProduct,
42+
>(
43+
getProductFromSomeAsyncProcessThatCanThrow: () => Promise<SomeProduct>,
44+
): Promise<Outcome<SomeProduct>> => {
45+
// eslint-disable-next-line no-restricted-syntax
46+
try {
47+
const productFromSomeSuccessfulAsyncProcess: SomeProduct = await getProductFromSomeAsyncProcessThatCanThrow();
48+
return Success.thatYielded(productFromSomeSuccessfulAsyncProcess);
49+
}
50+
catch (someException) {
51+
const someError = asError(someException);
52+
return Failure.dueTo(someError);
53+
}
54+
};
55+
56+
const attemptToSettle = async <
57+
SomeProduct,
58+
>(
59+
promisedProduct: Promise<SomeProduct>,
60+
): Promise<Outcome<SomeProduct>> => {
61+
const getPromisedProduct = () => promisedProduct;
62+
return attemptToEventually(getPromisedProduct);
63+
};
64+
65+
const Attempt = {
66+
to : attemptTo,
67+
toOpaquely : attemptToOpaquely,
68+
toEventually: attemptToEventually,
69+
toSettle : attemptToSettle,
70+
};
71+
72+
export default Attempt;

src/library/customTypes/NonTrivialString.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import Attempt from '@/library/Attempt';
2+
13
import type {
24
StaticStringParser,
35
} from '@/library/typeUtilities/StaticStringParser.ts';
@@ -22,12 +24,7 @@ export default class NonTrivialString
2224
public static nullableParsedFrom(givenSubject: string | null): NonTrivialString | null {
2325
if (givenSubject === null) return givenSubject;
2426

25-
try {
26-
return this.forciblyParsedFrom(givenSubject);
27-
}
28-
catch {
29-
return null;
30-
}
27+
return Attempt.toOpaquely(() => this.forciblyParsedFrom(givenSubject));
3128
}
3229

3330
public isEqualTo(that: NonTrivialString): boolean {

src/library/typeUtilities/Boolean.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type Is<
2+
LeftHandOperand,
3+
RightHandOperand,
4+
> =
5+
LeftHandOperand extends RightHandOperand
6+
? RightHandOperand extends LeftHandOperand
7+
? true :
8+
false :
9+
false;
10+
11+
export type Not<
12+
SomeBoolean extends boolean,
13+
> = SomeBoolean extends true
14+
? false
15+
: true;

src/library/typeUtilities/Filter.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
/**
2+
* Has the same effect as {@link Pick}, except it distributes over union types.
3+
*
4+
* Rather than mapping _kept_ keys,
5+
* it maps _all_ keys and then filters out the dropped ones at the end.
6+
*/
7+
export type Filter<
8+
SomeObject,
9+
SomeKeyToBeKept extends PropertyKey,
10+
> = {
11+
[EachKey in keyof SomeObject as Extract<EachKey, SomeKeyToBeKept>]: SomeObject[EachKey]
12+
};

0 commit comments

Comments
 (0)