본문 바로가기

특수 타입

모양을 모르는 값을 받는 unknown과 원시 값이 아닌 값을 나타내는 object, 함수의 흐름을 나타내는 void와 never, strictNullChecks에 따라 달라지는 null과 undefined의 할당 관계를 예제로 구분합니다.

앞 글의 기본 타입은 값의 종류를 분명히 알 때 씁니다. 그런데 실제 프로그램에서는 사용자의 입력이나 서버의 응답처럼 어떤 모양일지 미리 알 수 없는 값을 받기도 하고, 값을 돌려주지 않거나 아예 끝나지 않는 함수를 만들기도 합니다. 이런 상황을 나타내는 타입이 unknown과 object, void와 never, null과 undefined입니다.

이 타입들은 안전한 순서로 줄 세울 수 있는 것이 아니라, 각각 다른 질문에 답합니다. 아래 그림처럼 어떤 값을 받을 수 있는지, 받은 값으로 무엇을 할 수 있는지, 함수가 정상적으로 돌아오는지, 값이 비어 있을 수 있는지를 따로 확인합니다. 예제는 앞 글처럼 ts-first 프로젝트에 special-types.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.

unknown은 사용 전 좁히기를 요구하고 object는 비원시 값을 표현합니다. void의 결과 계약과 never의 정상 반환 불가는 다르며 null·undefined는 설정에 맞게 표현합니다.

TYPE / RUNTIME / CONTRACT

특수 타입은 값과 흐름의 의미로 선택한다

unknown·object·void·never·null·undefined를 안전성 순위로 나열하지 않습니다. 받는 값, 허용할 연산, 함수의 정상 반환, 결측 의미를 따로 확인합니다.

UNKNOWN / USE

모든 값 수신, 사용 전 좁히기

본문은 typeof string/number를 확인한 뒤 toUpperCase/toFixed를 호출합니다. object 분기는 null을 제외하지만 특정 속성의 모양까지 확인한 것은 아닙니다.

OBJECT / SHAPE

배열 · 함수도 비원시 값이다

object는 원시 타입을 제외하지만 name 같은 속성을 보장하지 않습니다. 구체적인 속성이 필요하면 객체 타입이나 interface를 사용합니다.

VOID / RESULT

정상 종료와 결과 미사용

본문 logMessage:void는 값을 돌려주지 않고 정상 종료합니다. ()=>void 콜백 계약에는 값을 반환하는 함수도 들어올 수 있으며 호출자는 그 결과를 사용하지 않습니다.

NEVER / CONTROL

정상적으로 돌아오는 값이 없다

throwError와 무한 루프는 never입니다. never는 다른 타입에 할당 가능하지만, throwError 호출은 예외를 던져 실제 할당할 값이 생기지 않습니다.

NULL / MEANING

없는 값을 계약에 명시한다

strictNullChecks=true의 nullableString:string|null은 null을 허용합니다. 본문의 느슨한 설정에서는 number에 null도 들어가지만 같은 동작을 strict 설정으로 확대하지 않습니다.

UNDEFINED / MEANING

생략과 undefined 쓰기는 설정도 확인

number|undefined는 undefined를 허용합니다. void에도 undefined가 허용됩니다. 선택적 속성의 생략과 명시적 undefined 쓰기는 exactOptionalPropertyTypes 등 실제 설정에 따라 구분합니다.

unknown은 사용 전 좁히기를 요구하고 object는 비원시 값을 표현합니다. void의 결과 계약과 never의 정상 반환 불가는 다르며 null·undefined는 설정에 맞게 표현합니다.

unknown

unknown은 어떤 값이든 받을 수 있다는 점에서 any와 비슷하지만, 받은 값을 바로 쓰지는 못하게 합니다. 속성을 읽거나 메서드를 부르려면 먼저 그 값이 어떤 타입인지 확인해서, 타입을 더 좁은 쪽으로 줄여야 합니다. 이렇게 조건문으로 타입의 범위를 줄이는 것을 타입 좁히기(narrowing)라고 합니다.

let incoming: unknown;

incoming = 123;
incoming = "hello";
incoming = { a: 1 };

if (typeof incoming === "string") {
  console.log(incoming.toUpperCase());
} else if (typeof incoming === "number") {
  console.log(incoming.toFixed(2));
} else if (typeof incoming === "object" && incoming !== null) {
  console.log("객체를 받았습니다.");
}
객체를 받았습니다.

unknown에는 숫자와 문자열, 객체를 차례로 넣을 수 있고, 마지막에 넣은 값이 객체이므로 세 번째 분기만 실행됩니다. 각 분기 안에서는 typeof 검사 덕분에 incoming이 string이나 number로 좁혀져서 그 타입의 메서드를 쓸 수 있습니다. 세 번째 분기에서 null을 따로 빼는 이유는, 자바스크립트에서 typeof null도 "object"이기 때문입니다.

검사 없이 바로 메서드를 부르면 진단이 나옵니다. if 밖에서 incoming.toUpperCase()를 호출하면 컴파일러는 이 값이 문자열이라고 확신할 수 없으므로 다음과 같이 보고합니다.

error TS18046: 'incoming' is of type 'unknown'.

as 키워드로 타입을 단언(assertion)해서 (incoming as string).toUpperCase()처럼 쓰면 진단은 사라집니다. 하지만 단언은 컴파일러에게 "이 값은 문자열이라고 믿어 달라"고 말할 뿐 실제 값을 바꾸거나 검사하지 않으므로, 값이 객체라면 실행 중에 toUpperCase가 없다는 오류로 멈춥니다. 모양을 모르는 값은 단언보다 실제로 확인하는 코드로 좁혀야 합니다.

비교anyunknown
어떤 값이든 넣을 수 있는가그렇다그렇다
확인 없이 속성과 메서드를 쓸 수 있는가그렇다진단이 나온다
다른 타입의 변수에 넣을 수 있는가그렇다any와 unknown에만 넣을 수 있다
잘못 쓴 코드를 실행 전에 알 수 있는가알 수 없다확인하지 않은 사용을 알려 준다

그래서 서버의 응답이나 JSON.parse의 결과처럼 모양을 아직 모르는 값은 any보다 unknown으로 받는 편이 안전합니다. 다음 함수는 숫자 점수만 소수 둘째 자리까지 표시하고, 그 밖의 값은 안내 문구로 바꿉니다.

function formatScore(input: unknown): string {
  if (typeof input === "number" && Number.isFinite(input)) {
    return input.toFixed(2);
  }
  return "숫자 점수 아님";
}

console.log(formatScore(3));
console.log(formatScore("3"));
3.00
숫자 점수 아님

typeof로 숫자인지 확인한 뒤, Number.isFinite로 NaN과 무한대를 한 번 더 걸러 냅니다. NaN과 Infinity도 typeof로는 "number"이기 때문입니다. 이 함수는 "3"처럼 숫자로 보이는 문자열을 숫자로 바꾸어 주지 않으므로, 문자열을 받아야 한다면 변환과 검증을 따로 써야 합니다.

object

object 타입은 원시 값이 아닌 모든 값을 나타냅니다. 원시 값은 number, bigint, string, boolean, symbol, null, undefined이고, 그 밖의 객체와 배열, 함수는 모두 object에 넣을 수 있습니다.

let container: object;

container = { title: "공지", views: 30 };
container = [1, 2, 3];
container = function () {
  return "hello";
};
console.log(typeof container);
function

마지막에 넣은 값이 함수이므로 typeof는 "function"을 출력합니다. container = 100;처럼 원시 값을 넣으면 Type 'number' is not assignable to type 'object'.로 진단됩니다.

object는 "원시 값이 아니다"라는 사실만 알려 주므로, 그 안에 어떤 속성이 있는지는 모릅니다. 그래서 { title: "공지", views: 30 }을 넣은 직후라도 container.title을 읽으면 Property 'title' does not exist on type 'object'.라는 TS2339가 나옵니다. 속성을 써야 하는 값이라면 다음 글에서 다루는 객체 타입이나 인터페이스로 그 모양을 직접 적습니다.

더 깊이: object와 {}와 Object의 차이

이름이 비슷한 타입이 셋 있어서 자주 헷갈립니다. 소문자 object는 이 절에서 본 대로 원시 값이 아닌 값입니다. 중괄호만 적은 빈 객체 타입(empty object type) {}는 '요구하는 속성이 하나도 없는 모양'이라는 뜻이어서, null과 undefined를 뺀 모든 값을 받습니다. 원시 값도 속성을 읽을 수 있는 값이므로 const n: {} = 1이나 const s: {} = "a"도 통과하고, 그래서 {}는 '아무 객체'가 아니라 '비어 있지 않은 아무 값'에 가깝습니다. 대문자 Object는 모든 객체가 물려받는 Object.prototype의 메서드를 적은 전역 타입이며, 공식 Handbook은 object와 Object가 다르다고 짚으면서 항상 object를 쓰라고 권합니다.

TypeScript 4.8은 {}의 이 성질을 바탕으로 strictNullChecks가 켜진 경우의 규칙을 다듬었습니다. 4.8부터 컴파일러는 unknown을 {} | null | undefined와 같은 성질로 보아, unknown 값을 그 유니온에 넣는 것을 허용합니다. 참거짓 검사로 좁힐 때도 같아서, if (x) 안에서 unknown인 x는 {}가 됩니다.

void와 never

void는 함수가 호출한 쪽에 쓸 만한 값을 돌려주지 않는다는 뜻입니다. return 문이 없거나 return;만 있는 함수가 여기에 해당하며, 이런 함수의 결과를 다른 타입의 변수에 넣으면 진단이 나옵니다.

function logMessage(message: string): void {
  console.log(message);
}

logMessage("반환값이 없는 함수입니다.");
반환값이 없는 함수입니다.

이 함수의 결과를 const result: string = logMessage("테스트");처럼 받으면 Type 'void' is not assignable to type 'string'.이 보고됩니다. 함수 선언에 : void를 적고 본문에서 return 1;처럼 값을 돌려주면, 그 return에서 Type 'number' is not assignable to type 'void'.가 나옵니다.

never는 함수가 정상적으로 돌아오지 않는다는 뜻입니다. 항상 예외를 던지거나 끝나지 않는 반복에 빠지는 함수의 반환 타입으로 쓰며, void가 "돌아오지만 값이 없다"라면 never는 "돌아오지 않는다"입니다.

function throwError(message: string): never {
  throw new Error(message);
}

function keepProcessing(): never {
  while (true) {
    // 계속 작업한다
  }
}

never는 모든 타입에 넣을 수 있으므로 const result: string = throwError("치명적인 오류");도 검사를 통과합니다. 다만 throwError는 예외를 던지고 돌아오지 않으므로, 실제로 result에 들어가는 값은 생기지 않습니다. 앞 글에서 본 것처럼, 모든 경우를 처리한 뒤 남은 값이 never인지 확인하는 데도 이 성질을 씁니다.

void에는 한 가지 예외가 있습니다. 함수 선언이 아니라 () => void라는 함수 타입에 함수를 넣을 때는, 값을 돌려주는 함수도 받을 수 있습니다. 이 타입은 "돌려준 값을 쓰지 않겠다"는 뜻이기 때문입니다.

const pending: string[] = [];
const recordReview: () => void = () => pending.push("검토");

recordReview();
console.log(pending.length);
1

push는 새 길이를 돌려주므로 실제로 recordReview()의 결과는 숫자지만, 타입은 void여서 호출한 쪽이 그 값을 쓸 수 없습니다. 이 규칙 덕분에 forEach처럼 반환값을 쓰지 않는 콜백 자리에 push를 부르는 화살표 함수를 그대로 넘길 수 있습니다. 두 경우의 차이는 공식 Handbook의 void 반환 설명에 있습니다.

null과 undefined

null과 undefined가 다른 타입에 들어갈 수 있는지는 strictNullChecks 설정에 따라 달라집니다. TypeScript 6.0에서는 strict가 기본으로 켜져 있어 strictNullChecks도 켜지지만, 설정 파일에서 "strictNullChecks": false를 적으면 이 검사만 따로 끌 수 있습니다. 그래서 같은 코드라도 프로젝트에 실제로 적용된 값을 확인해야 합니다.

strictNullChecks를 끄면 null과 undefined를 number나 string 같은 거의 모든 타입에 넣을 수 있습니다. 자바스크립트처럼 느슨하게 동작하는 대신, 값이 비어 있을 수 있다는 사실이 타입에 드러나지 않습니다.

// "strictNullChecks": false일 때
let looseNumber: number = 10;
looseNumber = null;
looseNumber = undefined;
console.log(looseNumber);
undefined

이 설정에서는 이어서 looseNumber.toFixed(1)을 써도 검사를 통과하지만, 실행하면 TypeError: Cannot read properties of undefined (reading 'toFixed')로 멈춥니다. 타입은 number라고 말하는데 실제 값은 비어 있어서, 오류가 실행할 때에야 드러나는 것입니다.

strictNullChecks를 켜면 null과 undefined는 자기 타입과 any, unknown에만 들어갈 수 있고, undefined는 void에도 들어갈 수 있습니다. 값이 비어 있을 수 있는 자리라면 유니온(union) 타입으로 그 가능성을 직접 적습니다.

// "strictNullChecks": true일 때
let strictNumber: number = 10;
// strictNumber = null;

let nullableTitle: string | null = "Hello";
nullableTitle = null;

let optionalCount: number | undefined = 50;
optionalCount = undefined;
console.log(nullableTitle, optionalCount);
null undefined

주석을 풀면 Type 'null' is not assignable to type 'number'.가 나옵니다. string | null은 문자열이거나 null이라는 뜻이어서 null을 넣을 수 있고, 이런 변수의 메서드를 쓰려면 먼저 null이 아닌지 확인해야 합니다. 이 교재는 strictNullChecks가 켜진 상태를 기준으로 설명하며, 유니온 타입은 유니온과 인터섹션 타입에서 자세히 다룹니다.

unknown 입력을 검증하고 내부 유니온으로 변환한 뒤 모든 경우를 처리합니다. 남은 값의 never 검사는 정적 누락 검사이며 런타임 validator와 역할이 다릅니다.

TYPE / RUNTIME / CONTRACT

입력 검증 뒤 닫힌 유니온의 누락을 검사한다

본문은 unknown의 typeof 좁히기와 never 반환을 설명합니다. 아래는 이를 이어 붙인 확장 설계입니다. guard 통과만으로 닫힌 도메인 유니온이 저절로 생기지는 않습니다.

입력 검증과 정적 누락 검사는 다른 경계검증→도메인변환→상태처리→never정적누락검사를연결하는5노드4간선개념흐름이다. 이절의formatScore는숫자검증예이며 닫힌유니온과누락검사의전체실습은다음절확장코드에서따른다. never가정상반환값을실행중생성한다는뜻이아니다.
unknown 입력모르는 외부 값
연결 관계unknown 입력 · 모르는 외부 값 → 값 · 필드 검증 · 실패 값은 거절
값 · 필드 검증실패 값은 거절
연결 관계값 · 필드 검증 · 실패 값은 거절 → 도메인 변환 · 닫힌 유니온 계약
도메인 변환닫힌 유니온 계약
연결 관계도메인 변환 · 닫힌 유니온 계약 → 각 상태 처리 · 판별 속성으로 좁히기
각 상태 처리판별 속성으로 좁히기
연결 관계각 상태 처리 · 판별 속성으로 좁히기 → 남은 값 = never · 정적 누락 검사
남은 값 = never정적 누락 검사
입력 검증과 정적 누락 검사는 다른 경계 · 모바일검증→도메인변환→상태처리→never정적누락검사를연결하는5노드4간선개념흐름이다. 이절의formatScore는숫자검증예이며 닫힌유니온과누락검사의전체실습은다음절확장코드에서따른다. never가정상반환값을실행중생성한다는뜻이아니다. 모바일에서도 노드와 연결 방향·독립 경로가 같다.
unknown 입력모르는 외부 값
연결 관계unknown 입력 · 모르는 외부 값 → 값 · 필드 검증 · 실패 값은 거절
값 · 필드 검증실패 값은 거절
연결 관계값 · 필드 검증 · 실패 값은 거절 → 도메인 변환 · 닫힌 유니온 계약
도메인 변환닫힌 유니온 계약
연결 관계도메인 변환 · 닫힌 유니온 계약 → 각 상태 처리 · 판별 속성으로 좁히기
각 상태 처리판별 속성으로 좁히기
연결 관계각 상태 처리 · 판별 속성으로 좁히기 → 남은 값 = never · 정적 누락 검사
남은 값 = never정적 누락 검사

RUNTIME / VALIDATION

모양과 필요한 값 조건을 확인

typeof와 필드 검사로 실제 입력을 확인합니다. 허용할 값의 범위까지 필요한 경우 함께 검사하고 실패를 거절합니다. 타입 주석이나 단언이 validator를 생성하지 않습니다.

DESIGN / CLOSED UNION

검증된 값을 도메인 계약으로 만든다

검증 결과를 닫힌 유니온의 하나로 변환합니다. 단순 object 검사만으로 특정 kind와 필드의 관계가 보장되는 것은 아닙니다.

STATIC / EXHAUSTIVENESS

모든 경우를 처리한 나머지만 never

닫힌 유니온에서 각 경우를 제거한 뒤 남은 값을 never에 할당합니다. 새 경우를 추가하고 처리하지 않으면 정적 진단이 발생합니다. 모든 성공 분기가 never 값을 반환하는 것은 아닙니다.

LESSON / EXTENSION

본문의 좁히기와 확장 설계를 구분

이 절의 코드는 unknown typeof 분기, object, void/never, null 설정 예입니다. 여기의 도메인 변환과 판별 유니온 전체 validator는 학습을 연결하는 추가 설계입니다. 다음 유니온 절의 확장 코드에서 실제 검증·분기·누락 검사를 연결해 실행합니다.

unknown 입력을 검증하고 내부 유니온으로 변환한 뒤 모든 경우를 처리합니다. 남은 값의 never 검사는 정적 누락 검사이며 런타임 validator와 역할이 다릅니다.

위 그림은 이 글의 타입들이 한 흐름에서 어떻게 이어지는지 보여 주는 확장 설계입니다. 바깥에서 받은 unknown 값을 검증해 프로그램 안에서 쓰는 정해진 모양으로 바꾸고, 그 경우를 모두 처리한 뒤 남은 값이 never인지 검사합니다. never 검사는 처리하지 않은 경우를 실행 전에 찾는 정적인 확인이어서, 실행 중에 값을 검증하는 코드를 대신하지는 않습니다.

정리

  • unknown은 어떤 값이든 받지만, 속성과 메서드를 쓰기 전에 typeof 같은 검사로 타입을 좁혀야 하며, 확인 없이 쓰면 TS18046이 나옵니다.
  • as 단언은 진단만 없앨 뿐 실제 값을 검사하지 않으므로, 모양을 모르는 값은 확인하는 코드로 좁힙니다.
  • object는 원시 값이 아닌 값을 나타내지만 속성의 모양은 모르므로, 속성을 쓰려면 객체 타입이나 인터페이스로 모양을 적습니다.
  • void는 돌아오지만 쓸 값이 없는 함수, never는 돌아오지 않는 함수를 나타내고, () => void 타입에는 값을 돌려주는 함수도 넣을 수 있습니다.
  • strictNullChecks가 켜져 있으면 null과 undefined를 다른 타입에 넣을 수 없으므로, 비어 있을 수 있는 자리는 string | null처럼 유니온으로 적습니다.

연습 문제

1. 점수 함수에 넘긴 특이한 값

위의 formatScore에 null, NaN, 2.5를 차례로 넘기면 각각 무엇을 돌려주나요? 조건문 대신 return (input as number).toFixed(2); 한 줄만 쓰면 같은 결과가 나오나요?

해설 보기

formatScore(null)과 formatScore(NaN)은 숫자 점수 아님을, formatScore(2.5)는 2.50을 돌려줍니다. null은 typeof가 "object"라 첫 검사에서, NaN은 Number.isFinite에서 걸러집니다. 단언만 쓰면 검사는 통과하지만 NaN은 "NaN"을 돌려주고, null이나 문자열을 넘기면 toFixed가 없어 실행 중에 오류로 멈추므로 같은 함수가 아닙니다.

2. 두 번 부른 콜백

위의 recordReview를 한 번 더 호출하면 pending.length는 얼마인가요? 그다음 const count: number = recordReview();를 추가하면 검사를 통과하나요?

해설 보기

push가 두 번 실행되었으므로 pending.length는 2입니다. 그러나 recordReview의 타입은 () => void이므로 호출 결과의 타입도 void이고, number 변수에 넣으면 Type 'void' is not assignable to type 'number'.가 나옵니다. 배열을 바꾼 작업의 결과와, 호출한 쪽이 쓸 수 있는 반환값의 타입은 서로 다른 문제입니다.

3. object에 넣을 수 있는 값

strictNullChecks가 켜진 상태에서 다음 네 줄 가운데 검사를 통과하는 것은 어느 것인가요? const a: object = [];, const b: object = null;, const c: object = "text";, const d: object = () => 1;

해설 보기

배열을 넣는 a와 함수를 넣는 d가 통과합니다. b는 null이 object에 들어갈 수 없어 Type 'null' is not assignable to type 'object'.가, c는 문자열이 원시 값이어서 Type 'string' is not assignable to type 'object'.가 나옵니다. typeof null이 "object"라는 실행 중의 결과와, 타입 검사에서 null이 object가 아니라는 규칙을 헷갈리지 않아야 합니다.

다음 글 객체와 인터페이스에서는 object보다 구체적으로, 객체가 가진 속성의 이름과 타입을 적는 객체 타입과 인터페이스를 다룹니다.