TypeScript를 쓰다 보면, 함수 반환 타입을 최대한 정확하게 표현하고 싶어지는 시점이 옵니다. undefined를 없애고, 에러 케이스를 분리하고, 성공과 실패를 명확히 나누고 싶어집니다.
처음에는 의도가 분명합니다. 호출부에서 분기를 줄이고, 타입을 보고 흐름을 이해할 수 있게 하자는 생각입니다. 그런데 어느 순간부터, 반환 타입을 좁히려는 시도가 오히려 코드를 읽기 어렵게 만들기 시작합니다.
가장 단순한 반환 타입
초기에는 보통 이런 형태로 시작합니다.
function findUser(id: string): User | null {
return repo.find(id);
}
null이면 없다는 뜻이고, 값이 있으면 그대로 사용합니다. 호출부에서는 간단히 분기하면 됩니다.
const user = findUser(id);
if (!user) {
// not found
return;
}
이 정도에서는 큰 불편이 없습니다. 문제는 여기서 “조금 더 명확하게” 만들기 시작할 때입니다.
성공과 실패를 나누고 싶어질 때
null 대신 에러를 명확히 표현하고 싶어집니다. 그래서 이런 타입이 등장합니다.
type FindUserResult =
| { ok: true; user: User }
| { ok: false; reason: 'NOT_FOUND' };
function findUser(id: string): FindUserResult {
const user = repo.find(id);
if (!user) {
return { ok: false, reason: 'NOT_FOUND' };
}
return { ok: true, user };
}
반환 타입만 보면 의도는 분명합니다. 하지만 호출부는 조금 달라집니다.
const result = findUser(id);
if (!result.ok) {
// handle error
return;
}
doSomething(result.user);
여기까지는 아직 감당할 만합니다. 문제는 이 패턴이 여러 단계로 이어질 때입니다.
반환 타입이 전파되기 시작한다
findUser를 사용하는 함수도 비슷한 반환 타입을 갖게 됩니다.
function loadProfile(id: string) {
const userResult = findUser(id);
if (!userResult.ok) {
return userResult;
}
return { ok: true, profile: buildProfile(userResult.user) };
}
여기서부터 반환 타입은 점점 복잡해집니다. 성공 케이스마다 담고 있는 값도 달라집니다.
호출부에서는
- ok를 먼저 확인하고
- 그 안의 값을 다시 꺼내고
- 다음 단계로 넘깁니다
코드가 잘못된 것은 아닙니다. 다만 읽는 데 걸리는 시간이 점점 늘어납니다.
Result 패턴이 일상이 될 때
이 패턴이 익숙해지면, 예외를 거의 쓰지 않게 됩니다.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
모든 함수가 Result를 반환하고, 모든 호출부가 분기를 합니다.
이 구조는 예측 가능하지만, 동시에 코드 양이 빠르게 늘어납니다. 작은 유틸 함수 하나에도 분기 코드가 따라붙기 시작합니다.
좁힌 타입이 오히려 흐름을 가리는 경우
반환 타입이 지나치게 정교해지면, 함수의 역할보다 타입 구조가 먼저 보이기 시작합니다.
특히 여러 함수를 연쇄적으로 호출할 때,
- 실제 로직은 단순한데
- 에러 전달을 위한 코드가 대부분을 차지하고
- 비즈니스 흐름이 눈에 잘 들어오지 않습니다
이 시점에서 “타입이 코드를 돕고 있는지, 아니면 타입을 처리하기 위해 코드가 존재하는지” 헷갈리기 시작합니다.
예외로 돌려놓는 선택
이런 복잡함을 겪고 나면, 일부 반환 타입은 다시 예외로 돌아갑니다.
function findUserOrThrow(id: string): User {
const user = repo.find(id);
if (!user) {
throw new UserNotFoundError(id);
}
return user;
}
이 함수는 반환 타입이 단순합니다. 호출부도 단순해집니다. 대신 예외가 어디서 처리되는지는 다시 고민해야 합니다.
어디까지 좁힐 것인가
반환 타입을 좁히는 시도 자체가 잘못된 것은 아닙니다. 문제는 그 기준이 흐려질 때입니다.
경험상 다음과 같은 경우에는 반환 타입이 복잡해지기 쉽습니다.
- 모든 실패를 타입으로 표현하려는 경우
- 레이어 경계를 넘어서까지 동일한 Result를 유지하는 경우
- 작은 함수까지 동일한 패턴을 강제하는 경우
이런 상황에서는 타입 안정성은 높아지지만, 코드의 흐름은 오히려 멀어지는 경우가 많았습니다.
함수 반환 타입을 좁히는 것은 의도를 드러내는 좋은 수단이 될 수 있습니다. 다만 그 시도가 과해지면, 타입을 처리하는 코드가 로직을 가리는 순간이 옵니다.
어떤 실패는 값으로, 어떤 실패는 예외로 남길지에 대한 선택은 명확한 답이 있는 문제가 아닙니다. 다만 반환 타입을 더 좁히기 전에, 그 타입이 실제로 읽는 사람을 돕고 있는지 한 번쯤은 돌아보게 되는 지점이 생깁니다.
'개발 > Typescript' 카테고리의 다른 글
| [TYPESCRIPT] 타입 가드를 붙이기 시작하면 자주 겪는 일들 (is, in, instanceof) (0) | 2026.02.12 |
|---|---|
| [TYPESCRIPT] 멀티 레이어를 지나면서 타입이 조금씩 어긋나는 순간들 (0) | 2026.02.11 |
| [TYPESCRIPT] DTO를 믿기 시작했을 때 놓치기 쉬운 지점들 (0) | 2026.02.09 |
| [TYPESCRIPT] 에러 타입을 나누기 시작하면 코드에서 달라지는 것들 (0) | 2026.02.08 |
| [TYPESCRIPT] 스키마 검증을 붙이기 시작하면 코드에서 달라지는 것들 (0) | 2026.02.07 |
