NonNullable 유틸리티 타입 완전 정복

TypeScript의 NonNullable<T>가 null과 undefined를 타입에서 제거하는 원리를 설명합니다. strictNullChecks와의 관계, filter(Boolean) 타입 강화 패턴, 제네릭 제약 조건, 재귀 DeepNonNullable 패턴을 정리합니다.

· 5 min read · PALDYN Team

지난 글에서 Promise를 재귀적으로 언래핑하는 Awaited를 살펴봤습니다. 이번에는 가장 흔한 타입 에러 원인인 nullundefined를 타입 레벨에서 제거하는 NonNullable<T>를 다룹니다.

NonNullable null/undefined 제거

내부 구현

// TypeScript 4.8+ 구현
type NonNullable<T> = T & {};

// 이전 구현 (4.8 이전)
type NonNullable<T> = T extends null | undefined ? never : T;

T & {}null이나 undefined와의 인터섹션이 never가 되는 성질을 이용합니다. null & {} = never, undefined & {} = never이므로 자동으로 제거됩니다. string & {} = string처럼 일반 타입은 그대로 유지됩니다.

type A = NonNullable<string | null | undefined>; // string
type B = NonNullable<number | null>;              // number
type C = NonNullable<null>;                       // never
type D = NonNullable<string>;                     // string (변화 없음)

strictNullChecks와의 관계

NonNullablestrictNullChecks: true가 있어야 의미가 있습니다.

// strictNullChecks: false일 때
// null과 undefined는 모든 타입의 서브타입 → NonNullable이 아무 효과 없음
let x: string = null; // 에러 없음

// strictNullChecks: true일 때 (권장)
let x: string = null; // Error: null은 string에 할당 불가
type Safe = NonNullable<string | null>; // string — 제대로 동작

tsconfig.json에서 "strict": true 또는 "strictNullChecks": true를 설정해야 합니다.

주요 사용 패턴

filter(Boolean) 타입 강화

JavaScript에서 array.filter(Boolean)은 런타임에 falsy 값을 제거하지만, TypeScript는 이 패턴을 자동으로 이해하지 못합니다.

const items = [1, null, 2, undefined, 3]; // (number | null | undefined)[]

// filter(Boolean)은 TypeScript 기본적으로 타입을 좁히지 못함
const filtered = items.filter(Boolean); // (number | null | undefined)[]

// 해결책: 타입 가드 함수
function isNotNull<T>(value: T): value is NonNullable<T> {
  return value != null;
}

const safe = items.filter(isNotNull); // number[]

TypeScript 5.5부터는 일부 경우에 filter(Boolean) 패턴을 자동으로 이해하지만, 명시적인 타입 가드가 더 안정적입니다.

제네릭 제약 조건

// T가 null/undefined가 아님을 보장
function assertDefined<T>(value: T, message: string): asserts value is NonNullable<T> {
  if (value == null) throw new Error(message);
}

function requireValue<T extends NonNullable<unknown>>(value: T): T {
  return value;
}

// 제네릭 타입에서 null 제거
type Defined<T> = {
  [K in keyof T as T[K] extends null | undefined ? never : K]: T[K];
};

DOM 요소 안전 조회

function getElement<T extends Element>(selector: string): NonNullable<T | null> {
  const el = document.querySelector<T>(selector);
  if (!el) throw new Error(`Element not found: ${selector}`);
  return el;
}

// 반환 타입이 T (null 아님)
const button = getElement<HTMLButtonElement>("#submit");
button.click(); // 안전

실전 패턴

NonNullable 실전 패턴

배열 압축

// compact: falsy 값 제거 (null, undefined, 0, "", false 포함)
// null/undefined만 제거하는 버전
function compact<T>(arr: (T | null | undefined)[]): T[] {
  return arr.filter(isNotNull);
}

function isNotNull<T>(value: T | null | undefined): value is NonNullable<T> {
  return value != null;
}

const ids = [1, null, 2, undefined, 3];
const cleanIds = compact(ids); // number[]
cleanIds.forEach(id => console.log(id.toFixed())); // 안전

안전한 Map 조회

class TypedMap<K, V> {
  private map = new Map<K, V>();

  set(key: K, value: NonNullable<V>): this {
    this.map.set(key, value);
    return this;
  }

  getOrThrow(key: K): NonNullable<V> {
    const value = this.map.get(key);
    if (value == null) throw new Error(`Key not found: ${String(key)}`);
    return value as NonNullable<V>;
  }
}

DeepNonNullable 커스텀 유틸리티

NonNullable은 최상위 타입만 처리합니다. 중첩 객체까지 처리하려면 재귀 타입이 필요합니다.

type DeepNonNullable<T> = T extends object
  ? { [K in keyof T]: DeepNonNullable<T[K]> }
  : NonNullable<T>;

interface UserMaybeNull {
  name: string | null;
  address: {
    city: string | null;
    zip: string | undefined;
  } | null;
}

type UserClean = DeepNonNullable<UserMaybeNull>;
// → { name: string; address: { city: string; zip: string } }

NonNullable과 narrowing의 관계

NonNullable은 타입 레벨 연산이고, 런타임에서 실제로 null을 제거하려면 narrowing이 필요합니다.

function processUser(user: User | null | undefined): void {
  // 런타임 narrowing
  if (user == null) return;

  // 이 지점에서 user의 타입은 User — NonNullable<User | null | undefined>와 동일
  console.log(user.name);
}

타입 단언(user!)보다 명시적 narrowing이 더 안전합니다. NonNullable은 타입 정의 레벨에서 null 불가를 표현할 때, narrowing은 런타임에서 null을 걸러낼 때 사용합니다.


지난 글: Awaited 유틸리티 타입 완전 정복

다음 글: InstanceType과 ConstructorParameters 완전 정복


읽어주셔서 감사합니다. 😊