Skip to content

Commit 5e0cb33

Browse files
refactor: Effect Language Service 도입 및 타입 안전성·DRY 개선
- @effect/language-service 설치 및 tsconfig.json plugin 설정 - as 캐스팅 제거: isTaggedDefect 타입 가드 도입, isErrorResponse in 연산자 narrowing - DRY: DefaultService에 getWithQuery 헬퍼 추출, 6개 서비스 메서드 간소화 - Effect.gen → Effect.flatMap 전환: uploadFile, reserveGroup, addMessagesToGroup - Effect LSP diagnostic 반영: Schema.decodeUnknown + mapError, Effect.void, yieldable error 직접 yield Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 10527b1 commit 5e0cb33

11 files changed

Lines changed: 152 additions & 184 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
},
4949
"devDependencies": {
5050
"@biomejs/biome": "2.4.10",
51+
"@effect/language-service": "^0.85.1",
5152
"@effect/vitest": "^0.29.0",
5253
"@types/node": "^25.5.2",
5354
"dotenv": "^17.4.1",

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/errors/defaultError.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,11 @@ Response: ${this.responseBody?.substring(0, 500) ?? '(empty)'}`;
154154

155155
export const isErrorResponse = (value: unknown): value is ErrorResponse => {
156156
if (value == null || typeof value !== 'object') return false;
157-
const obj = value as Record<string, unknown>;
157+
if (!('errorCode' in value) || !('errorMessage' in value)) return false;
158158
return (
159-
typeof obj.errorCode === 'string' &&
160-
obj.errorCode !== '' &&
161-
typeof obj.errorMessage === 'string' &&
162-
obj.errorMessage !== ''
159+
typeof value.errorCode === 'string' &&
160+
value.errorCode !== '' &&
161+
typeof value.errorMessage === 'string' &&
162+
value.errorMessage !== ''
163163
);
164164
};

src/lib/effectErrorHandler.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,30 @@ import {
44
UnhandledExitError,
55
} from '../errors/defaultError';
66

7+
const isTaggedDefect = (
8+
value: unknown,
9+
): value is {readonly _tag: string; readonly message?: unknown} =>
10+
value !== null &&
11+
typeof value === 'object' &&
12+
'_tag' in value &&
13+
typeof value._tag === 'string';
14+
715
/**
816
* Defect(예측되지 않은 에러)에서 정보 추출
917
*/
1018
const extractDefectInfo = (
1119
defect: unknown,
1220
): {summary: string; details: string} => {
13-
if (defect !== null && typeof defect === 'object') {
14-
const obj = defect as Record<string, unknown>;
15-
16-
if ('_tag' in defect && typeof obj._tag === 'string') {
17-
const tag = obj._tag;
18-
const message = 'message' in defect ? String(obj.message) : '';
19-
return {
20-
summary: `${tag}${message ? `: ${message}` : ''}`,
21-
details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`,
22-
};
23-
}
21+
if (isTaggedDefect(defect)) {
22+
const tag = defect._tag;
23+
const message = defect.message != null ? String(defect.message) : '';
24+
return {
25+
summary: `${tag}${message ? `: ${message}` : ''}`,
26+
details: `Tagged Error [${tag}]: ${JSON.stringify(defect, null, 2)}`,
27+
};
28+
}
2429

30+
if (defect !== null && typeof defect === 'object') {
2531
const keys = Object.keys(defect);
2632
const summary =
2733
keys.length > 0

src/lib/schemaUtils.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,13 @@ export const decodeWithBadRequest = <A, I>(
1111
schema: Schema.Schema<A, I>,
1212
data: unknown,
1313
): Effect.Effect<A, BadRequestError> =>
14-
Effect.try({
15-
try: () => Schema.decodeUnknownSync(schema)(data),
16-
catch: error =>
14+
Effect.mapError(
15+
Schema.decodeUnknown(schema)(data),
16+
error =>
1717
new BadRequestError({
18-
message: error instanceof Error ? error.message : String(error),
18+
message: error.message,
1919
}),
20-
});
20+
);
2121

2222
/**
2323
* stringDateTransfer를 Effect로 감싸 InvalidDateError가 Defect가 되지 않도록 합니다.
@@ -35,7 +35,7 @@ export const safeDateTransfer = (
3535
message: error instanceof Error ? error.message : String(error),
3636
}),
3737
})
38-
: Effect.succeed(undefined);
38+
: Effect.void;
3939

4040
/**
4141
* formatWithTransfer를 Effect로 감싸 InvalidDateError가 Defect가 되지 않도록 합니다.

src/services/defaultService.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
import {AuthenticationParameter} from '@lib/authenticator';
22
import {defaultFetcherEffect} from '@lib/defaultFetcher';
33
import {runSafePromise} from '@lib/effectErrorHandler';
4+
import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils';
5+
import stringifyQuery from '@lib/stringifyQuery';
6+
import {Schema} from 'effect';
47
import * as Effect from 'effect/Effect';
58
import type {
69
ApiKeyError,
10+
BadRequestError,
711
ClientError,
812
DefaultError,
13+
InvalidDateError,
914
NetworkError,
1015
ServerError,
1116
} from '../errors/defaultError';
@@ -51,4 +56,36 @@ export default class DefaultService {
5156
): Promise<R> {
5257
return runSafePromise(this.requestEffect<T, R>(parameter));
5358
}
59+
60+
protected getWithQuery<A, R>(config: {
61+
schema: Schema.Schema<A>;
62+
finalize: (validated?: A) => object;
63+
url: string;
64+
data?: unknown;
65+
}): Effect.Effect<
66+
R,
67+
| ApiKeyError
68+
| ClientError
69+
| ServerError
70+
| NetworkError
71+
| DefaultError
72+
| BadRequestError
73+
| InvalidDateError
74+
> {
75+
const reqEffect = this.requestEffect.bind(this);
76+
return Effect.gen(function* () {
77+
const validated = config.data
78+
? yield* decodeWithBadRequest(config.schema, config.data)
79+
: undefined;
80+
const payload = yield* safeFinalize(() => config.finalize(validated));
81+
const parameter = stringifyQuery(payload, {
82+
indices: false,
83+
addQueryPrefix: true,
84+
});
85+
return yield* reqEffect<never, R>({
86+
httpMethod: 'GET',
87+
url: `${config.url}${parameter}`,
88+
});
89+
});
90+
}
5491
}

src/services/iam/iamService.ts

Lines changed: 15 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import {runSafePromise} from '@lib/effectErrorHandler';
2-
import {decodeWithBadRequest, safeFinalize} from '@lib/schemaUtils';
3-
import stringifyQuery from '@lib/stringifyQuery';
42
import {
53
finalizeGetBlacksRequest,
64
type GetBlacksRequest,
@@ -19,7 +17,6 @@ import {
1917
import {GetBlacksResponse} from '@models/responses/iam/getBlacksResponse';
2018
import {GetBlockGroupsResponse} from '@models/responses/iam/getBlockGroupsResponse';
2119
import {GetBlockNumbersResponse} from '@models/responses/iam/getBlockNumbersResponse';
22-
import * as Effect from 'effect/Effect';
2320
import DefaultService from '../defaultService';
2421

2522
export default class IamService extends DefaultService {
@@ -29,23 +26,12 @@ export default class IamService extends DefaultService {
2926
* @returns GetBlacksResponse
3027
*/
3128
async getBlacks(data?: GetBlacksRequest): Promise<GetBlacksResponse> {
32-
const reqEffect = this.requestEffect.bind(this);
3329
return runSafePromise(
34-
Effect.gen(function* () {
35-
const validated = data
36-
? yield* decodeWithBadRequest(getBlacksRequestSchema, data)
37-
: undefined;
38-
const payload = yield* safeFinalize(() =>
39-
finalizeGetBlacksRequest(validated),
40-
);
41-
const parameter = stringifyQuery(payload, {
42-
indices: false,
43-
addQueryPrefix: true,
44-
});
45-
return yield* reqEffect<never, GetBlacksResponse>({
46-
httpMethod: 'GET',
47-
url: `iam/v1/black${parameter}`,
48-
});
30+
this.getWithQuery({
31+
schema: getBlacksRequestSchema,
32+
finalize: finalizeGetBlacksRequest,
33+
url: 'iam/v1/black',
34+
data,
4935
}),
5036
);
5137
}
@@ -58,23 +44,12 @@ export default class IamService extends DefaultService {
5844
async getBlockGroups(
5945
data?: GetBlockGroupsRequest,
6046
): Promise<GetBlockGroupsResponse> {
61-
const reqEffect = this.requestEffect.bind(this);
6247
return runSafePromise(
63-
Effect.gen(function* () {
64-
const validated = data
65-
? yield* decodeWithBadRequest(getBlockGroupsRequestSchema, data)
66-
: undefined;
67-
const payload = yield* safeFinalize(() =>
68-
finalizeGetBlockGroupsRequest(validated),
69-
);
70-
const parameter = stringifyQuery(payload, {
71-
indices: false,
72-
addQueryPrefix: true,
73-
});
74-
return yield* reqEffect<never, GetBlockGroupsResponse>({
75-
httpMethod: 'GET',
76-
url: `iam/v1/block/groups${parameter}`,
77-
});
48+
this.getWithQuery({
49+
schema: getBlockGroupsRequestSchema,
50+
finalize: finalizeGetBlockGroupsRequest,
51+
url: 'iam/v1/block/groups',
52+
data,
7853
}),
7954
);
8055
}
@@ -87,23 +62,12 @@ export default class IamService extends DefaultService {
8762
async getBlockNumbers(
8863
data?: GetBlockNumbersRequest,
8964
): Promise<GetBlockNumbersResponse> {
90-
const reqEffect = this.requestEffect.bind(this);
9165
return runSafePromise(
92-
Effect.gen(function* () {
93-
const validated = data
94-
? yield* decodeWithBadRequest(getBlockNumbersRequestSchema, data)
95-
: undefined;
96-
const payload = yield* safeFinalize(() =>
97-
finalizeGetBlockNumbersRequest(validated),
98-
);
99-
const parameter = stringifyQuery(payload, {
100-
indices: false,
101-
addQueryPrefix: true,
102-
});
103-
return yield* reqEffect<never, GetBlockNumbersResponse>({
104-
httpMethod: 'GET',
105-
url: `iam/v1/block/numbers${parameter}`,
106-
});
66+
this.getWithQuery({
67+
schema: getBlockNumbersRequestSchema,
68+
finalize: finalizeGetBlockNumbersRequest,
69+
url: 'iam/v1/block/numbers',
70+
data,
10771
}),
10872
);
10973
}

src/services/messages/groupService.ts

Lines changed: 28 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import {GroupId} from '@internal-types/commonTypes';
22
import {runSafePromise} from '@lib/effectErrorHandler';
3-
import {
4-
decodeWithBadRequest,
5-
safeFinalize,
6-
safeFormatWithTransfer,
7-
} from '@lib/schemaUtils';
3+
import {decodeWithBadRequest, safeFormatWithTransfer} from '@lib/schemaUtils';
84
import stringifyQuery from '@lib/stringifyQuery';
95
import {
106
finalizeGetGroupsRequest,
@@ -80,24 +76,19 @@ export default class GroupService extends DefaultService {
8076
): Promise<AddMessageResponse> {
8177
const reqEffect = this.requestEffect.bind(this);
8278
return runSafePromise(
83-
Effect.gen(function* () {
84-
const validatedMessages = yield* decodeWithBadRequest(
85-
requestSendMessageSchema,
86-
messages,
87-
);
88-
89-
const requestBody: GroupMessageAddRequest = {
90-
messages: Array.isArray(validatedMessages)
91-
? validatedMessages
92-
: [validatedMessages],
93-
};
94-
95-
return yield* reqEffect<GroupMessageAddRequest, AddMessageResponse>({
96-
httpMethod: 'PUT',
97-
url: `messages/v4/groups/${groupId}/messages`,
98-
body: requestBody,
99-
});
100-
}),
79+
Effect.flatMap(
80+
decodeWithBadRequest(requestSendMessageSchema, messages),
81+
validatedMessages =>
82+
reqEffect<GroupMessageAddRequest, AddMessageResponse>({
83+
httpMethod: 'PUT',
84+
url: `messages/v4/groups/${groupId}/messages`,
85+
body: {
86+
messages: Array.isArray(validatedMessages)
87+
? validatedMessages
88+
: [validatedMessages],
89+
},
90+
}),
91+
),
10192
);
10293
}
10394

@@ -122,20 +113,15 @@ export default class GroupService extends DefaultService {
122113
async reserveGroup(groupId: GroupId, scheduledDate: Date | string) {
123114
const reqEffect = this.requestEffect.bind(this);
124115
return runSafePromise(
125-
Effect.gen(function* () {
126-
const formattedScheduledDate =
127-
yield* safeFormatWithTransfer(scheduledDate);
128-
return yield* reqEffect<
129-
ScheduledDateSendingRequest,
130-
GroupMessageResponse
131-
>({
132-
httpMethod: 'POST',
133-
url: `messages/v4/groups/${groupId}/schedule`,
134-
body: {
135-
scheduledDate: formattedScheduledDate,
136-
},
137-
});
138-
}),
116+
Effect.flatMap(
117+
safeFormatWithTransfer(scheduledDate),
118+
formattedScheduledDate =>
119+
reqEffect<ScheduledDateSendingRequest, GroupMessageResponse>({
120+
httpMethod: 'POST',
121+
url: `messages/v4/groups/${groupId}/schedule`,
122+
body: {scheduledDate: formattedScheduledDate},
123+
}),
124+
),
139125
);
140126
}
141127

@@ -159,23 +145,12 @@ export default class GroupService extends DefaultService {
159145
* @param data 그룹 정보 상세 조회용 request 데이터
160146
*/
161147
async getGroups(data?: GetGroupsRequest): Promise<GetGroupsResponse> {
162-
const reqEffect = this.requestEffect.bind(this);
163148
return runSafePromise(
164-
Effect.gen(function* () {
165-
const validated = data
166-
? yield* decodeWithBadRequest(getGroupsRequestSchema, data)
167-
: undefined;
168-
const payload = yield* safeFinalize(() =>
169-
finalizeGetGroupsRequest(validated),
170-
);
171-
const parameter = stringifyQuery(payload, {
172-
indices: false,
173-
addQueryPrefix: true,
174-
});
175-
return yield* reqEffect<never, GetGroupsResponse>({
176-
httpMethod: 'GET',
177-
url: `messages/v4/groups${parameter}`,
178-
});
149+
this.getWithQuery({
150+
schema: getGroupsRequestSchema,
151+
finalize: finalizeGetGroupsRequest,
152+
url: 'messages/v4/groups',
153+
data,
179154
}),
180155
);
181156
}

0 commit comments

Comments
 (0)