타입스크립트에서 null을 처리하는 방법
Java로 개발할 때 가장 중요하게 여겼던 것은 Null에 대한 처리이다.특히 NullPointerException(이하 NPE)이 발생하면 우리 서비스가 갑자기 종료될 수 있기에 사용자 경험에 매우 좋지 않다. 타입스크립
securityinit.tistory.com
이전 글의 마지막을 보면
[함수 호출 -> null 체크 -> 실패하면 null 반환 -> 성공하면 다음 단계]
이러한 상황에 대한 처리를 고민했다.
Kotlin의 let 체이닝, Java의 Optional 등으로 처리할 수 있지만, Typescript에선 방법이 따로 없었다.
Typescript에선 if (null) return null;이라는 구조를 계속 반복했었다.
이번 글에선 이런 구조를 체이닝을 통해 한 줄로 해결할 수 있도록 커스텀 타입을 만들어보고 적용해 보자.
Java의 Optional<T> 과 Kotlin의 let
Java의 Optional<T> , Kotlin의 let 이 두 문법은 이런 역할을 한다.
Null 일 수도 있는 값을 컨테이너에 담는다.
컨테이너 안에 값이 있을 경우에만 다음 함수를 실행하고,
없다면 조기 종료한다.
특히 Java Optional의 경우, 래퍼 타입으로 클래스이다.
클래스이므로 여러 메서드를 가질 수 있다.
Typescript로 Java의 Optional과 비슷한 타입을 구현해 보자!
Option 타입 구현하기
type Nullable<T> = T | null | undefined;
export interface IOption<T> {
// 값이 있는지 확인
isPresent(): boolean;
// 값이 있는 경우 함수 적용하고 그 결과를 Option으로 래핑 (Java의 map)
map<U>(fn: (value: T) => U): IOption<U>;
// 값이 있는 경우 함수 적용하고 그 결과는 Option 이어야 함. (Java의 flatMap)
flatMap<U>(fn: (value: T) => IOption<U>): IOption<U>;
// 값이 없으면 기본값 반환
orElse<D>(defaultValue: D): T | D;
// 값이 있으면 그 값을 반환하고, 없으면 null 반환
getOrElse<D>(defaultValue: D): T | D;
}
위와 같이 Java의 Optional <T>과 비슷한 역할을 하는 커스텀 타입과 인터페이스를 구현했다.
이제 값이 있는 경우(some)와 없는 경우(none), 위 인터페이스를 상속받아 클래스를 구현해 보자.
None 상태 구현
None은 값이 없는 상태이다.
값이 없는 경우, 모든 메서드는 동일하게 동작해야 하기에 싱글톤으로 구현해 보자.
import { IOption } from "./IOption";
class None implements IOption<never> {
// 싱글톤
static readonly INSTANCE: None = new None();
private constructor() {}
isPresent(): boolean {
return false;
}
map<U>(_fn: (value: never) => U): IOption<U> {
return None.INSTANCE as IOption<U>;
}
flatMap<U>(_fn: (value: never) => IOption<U>): IOption<U> {
return None.INSTANCE as IOption<U>;
}
orElse<D>(defaultValue: D): never | D {
return defaultValue;
}
getOrElse<D>(defaultValue: D): never | D {
return defaultValue;
}
}
// 쉽게 None 클래스의 인스턴스를 사용하기 위함
export const none = <T>(): IOption<T> => None.INSTANCE as IOption<T>;
왜 never 타입으로 정의했을까?
Typescript에서 never란 "절대 발생할 수 없는 값"을 나타낸다.
never타입엔 어떤 값도 할당할 수 없다.
그래서 어쩌란 걸까?
지금 우리가 구현하는 None은 값이 비어있는 상태를 의미한다.(null 또는 undefined)
즉, string이든, number 든 어떤 타입의 값도 갖고 있지 않다.
그러므로 None 내부에서 값을 꺼내려고 실행하면 안 되고, 그런 경우는 막아줘야 한다.
// None은 내부적으로 T 타입의 값을 가지지만,
// 그 T 타입이 '절대 존재하지 않는 타입 (never)'이다.
class None implements IOption<never> {
// ...
}
이렇게 정의하면, None 인스턴스 내부에서 값을 꺼내려고 시도하거나, 그런 메서드가 있다면, 타입 체커가 해당 값이 never임을 알고 컴파일 에러를 뱉는다.
이렇게 타입 안정성을 가져갈 수 있다.
추가로 싱글톤 패턴을 사용해 메모리 효율성을 높일 수 있다.
None 은 IOption<string>이든 IOption<number> 든 값이 없는 상태이므로 항상 동일하다.
const emptyString = none<string>(); // IOption<string> (실제로는 None.INSTANCE)
const emptyNumber = none<number>(); // IOption<number> (실제로는 None.INSTANCE)
위와 같은 경우엔 실제로 두 값 모두 메모리상 동일한 None 객체를 참조한다.
never 타입은 모든 타입의 서브 타입이다.
무슨 말이냐~ 하면 그 어떤 타입으로도 캐스팅될 수 있다는 뜻이다.
즉, 값이 없는 모든 경우, T 타입을 never로 캐스팅할 수 있다.
IOption<T> 로 정의한 수많은 변수를 사용할 때 값이 없는 경우가 오면, 전부 하나의 None.INSTANCE를 참조하게 된다.
이렇게 메모리 효율성도 챙길 수 있다.
Some 상태 구현
import { IOption } from '../types';
class Some<T> implements IOption<T> {
constructor(private readonly value: T) {
}
isPresent(): boolean {
return true;
}
map<U>(fn: (value: T) => U): IOption<U> {
return new Some(fn(this.value));
}
flatMap<U>(fn: (value: T) => IOption<U>): IOption<U> {
return fn(this.value);
}
orElse<D>(_defaultValue: D): T | D {
return this.value;
}
getOrElse<D>(_defaultValue: D): T | D {
return this.value;
}
}
export const some = <T>(value: T): IOption<T> => new Some(value);
Java에서 Optional.ofNullable()는 매우 자주 사용된다.
Nullable 값을 Optional 타입으로 변환해 주는 메서드인데, 동일하게 구현해 보자.
import { IOption, Nullable } from './types';
import { none } from './implementation/none';
import { some } from './implementation/some';
export const of = <T>(value: Nullable<T>): IOption<T> => {
if (value === null || value === undefined) {
return none();
}
return some(value);
}
이렇게 null 또는 undefined 값이 들어온 경우, 실제 값이 있는 경우 모두 of를 통해 처리할 수 있게 되었다.
실제 적용
그럼 우리가 이전 글에서 문제 삼았던 코드를 정리해 보자!
function processUserData(userId: string) {
const user = findUser(userId);
if (!user) return null;
const validated = validateUser(user);
if (!validated) return null;
const enriched = enrichUserData(validated);
if (!enriched) return null;
const formatted = formatForDisplay(enriched);
if (!formatted) return null;
return formatted;
}
이러한 코드가 다음과 같이 정리된다.
import { of } from './option';
function findUser(userId: string) : User | null {}
function validateUser(user: User) : ValidatedUser | null {}
function enrichUserData(user: ValidatedUser) : EnrichedUser | null {}
function formatForDisplay(user: EnrichedUser) : FormattedUser | null {}
function processUser(userId: string): FormattedData | null {
return of(findUser(userId))
.flatMap(user => of(validateUser(user)))
.flatMap(validUser => of(enrichUserData(validUser)))
.flatMap(enrichedUser => of(formatForDisplay(enrichedUser)))
.getOrElse(null);
}
각 체인에서 메서드들(findUser, validateUser 등)이 null을 반환할 수 있으므로, flatMap을 사용해줘야 한다!
만약 map을 사용한다면 null 값이 반한 되더라도, 그대로 IOption<T> 컨테이너 안에 들어가게 된다.
그럼 타입이 IOption<T | null> 상태가 되고, Some 상태로 판단하게 된다.
null 체크가 깨져버린다.
flatMap의 경우 IOption<T>를 반환하므로 null 값이 들어온다면 바로 none이 반환되므로 체이닝 중간에 null 또는 undefined 값이 들어왔음을 알고 체이닝을 종료시킨다.
이렇게 커스텀 Option을 직접 구현해 보았다.
사실 이러한 구조를 모나드 패턴이라 부른다.
값을 래핑해 컨테이너에 넣고 여러 연산을 체이닝 할 수 있게 해주는 패턴이다.
다음 글에선 타입스크립트에 숨어있는 모나드 패턴에 대해 더 알아보자!
참고
[Scala] 예외 처리 ( Option, Either, Try ) - Data Engineer
1. 스칼라 예외처리 Scala에서는 JVM 기반 언어 최대의 적인 NPE(NullPointerException)를 functional하게 handling 할 수 있는 다양한 수단을 제공하고 있다. Scala의 exception handling 3인방인 Option, Either, Try 에 대해
wonyong-jang.github.io