TypeScript의 unknown, any 그리고 never

TypeScript는 JavaScript에 없는 새로운 타입들이 있는데 최근에 TypeScript로 개발을 하며 unknown, any 그리고 never 세 가지 타입의 차이점을 제대로 이해하지 않고 있다는 생각이 들어 따로 정리해보았습니다.

Unknown

unknown은 TypeScript의 탑 타입(Top Type)입니다. TypeScript에 존재하고, 존재 할 수 있는 모든 타입들을 포함하여 어떤 값이든 가질 수 있지만, 그로 인해 모든 타입이 공통적으로 할 수 있는 연산 외에는 할 수 있는 것이 아무것도 없습니다. 그래서 이름 그대로 값이 어떤 타입인지 알 수 없는(unknown) 타입이기 때문에 unknown 타입 변수는 사용할 때 어떤 타입인지 다시 한번 명시를 해주어야 합니다.

let myVar: unknown = 'hello';

// 이 변수의 타입은 unknown이므로 어떤 타입의 값이든 할당과 재할당이 가능
myVar = 42;

// **오류** myVar 변수의 타입이 명확하지 않으므로 number 타입 변수에 값 할당이 불가능
let age: number = myVar;

// unknown 타입 변수는 이렇게 사용할 때 타입을 명시해주어야 함
let age: number = (myVar as number);
let myVar: unknown = 'hello';

// 이 변수의 타입은 unknown이므로 어떤 타입의 값이든 할당과 재할당이 가능
myVar = 42;

// **오류** myVar 변수의 타입이 명확하지 않으므로 number 타입 변수에 값 할당이 불가능
let age: number = myVar;

// unknown 타입 변수는 이렇게 사용할 때 타입을 명시해주어야 함
let age: number = (myVar as number);

unknown 타입 변수에 대해 타입 검사가 된 후에는 타입을 명시해주지 않아도 됩니다.

const flag: unknown = true;

if (flag === true) {
    // if 조건문에서 엄격한 비교를 통해 boolean 값인지 확인했으므로
    // 새 boolean 변수에 대입을 할 때에는 타입을 명시하지 않아도 됨
    const something: boolean = flag;
    
    // ...
}

if (typeof maybe === 'string') {
  // typeof 연산자를 사용하여 타입을 확인한 뒤에도 타입을 명시하지 않아도 됨
  const text: string = maybe;
}
const flag: unknown = true;

if (flag === true) {
    // if 조건문에서 엄격한 비교를 통해 boolean 값인지 확인했으므로
    // 새 boolean 변수에 대입을 할 때에는 타입을 명시하지 않아도 됨
    const something: boolean = flag;
    
    // ...
}

if (typeof maybe === 'string') {
  // typeof 연산자를 사용하여 타입을 확인한 뒤에도 타입을 명시하지 않아도 됨
  const text: string = maybe;
}

Any

타입 검사를 항상 만족하여 어떤 값이든 바로 대입하고 사용할 수 있는, TypeScript에서 가장 유용하지만 가장 주의해서 사용해야 하는 마법과 같은 타입입니다. JavaScript로 작성된 모듈을 최소한의 수정으로 사용하거나, 혹은 기존의 JavaScript 코드를 TypeScript로 재작성하는 작업을 할 때 이 any라는 마법 같은 타입을 사용하면 별다른 작업 없이 코드가 동작하지만, 반대로 타입 검사를 항상 만족하므로 의도치 않은 형 변환이나 전혀 예상하지 못한 의도되지 않은 타입의 값이 대입되는 등 여러 사이드 이펙트를 일으켜 안전성이 낮아지기 때문에 조심해야 합니다.

let value: any = 42;

// 지금 value에는 number타입의 값이 할당되어 있고
// number 타입은 runTask()라는 메소드를 가지고 있지 않지만
// any 타입이므로 컴파일러가 별도로 확인을 하지 않아 문제가 없다고 판단함
value.runTask();


// number 타입은 toFixed() 메소드를 가지고 있으므로 문제가 없음
// 하지만 역시 컴파일러가 메소드의 존재 여부를 별도로 확인을 하지는 않음
value.toFixed();
let value: any = 42;

// 지금 value에는 number타입의 값이 할당되어 있고
// number 타입은 runTask()라는 메소드를 가지고 있지 않지만
// any 타입이므로 컴파일러가 별도로 확인을 하지 않아 문제가 없다고 판단함
value.runTask();


// number 타입은 toFixed() 메소드를 가지고 있으므로 문제가 없음
// 하지만 역시 컴파일러가 메소드의 존재 여부를 별도로 확인을 하지는 않음
value.toFixed();

객체에 존재하지 않는 프로퍼티에 접근해도 컴파일러가 검사하지 않기 때문에 아래와 같은 코드도 typescript는 문제가 없다고 판단합니다. 물론 런타임에는 문제가 됩니다.

const user: any = {};

user.notifications.latest.messge;
const user: any = {};

user.notifications.latest.messge;

Never

앞서 말한 any 타입은 모든 타입을 포함하는 슈퍼셋 타입이라고 설명했습니다. 반대로 never는 모든 타입의 하위 타입입니다. 그래서 어떤 다른 값도 never 타입에 할당할 수 없습니다.

// never 변수에는 어떤 값도 할당할 수 없습니다.
// 그래서 아래의 두 코드는 TypeScript에서 컴파일 오류가 발생합니다.
const first: never = 42;
const second: never = 'some text';
// never 변수에는 어떤 값도 할당할 수 없습니다.
// 그래서 아래의 두 코드는 TypeScript에서 컴파일 오류가 발생합니다.
const first: never = 42;
const second: never = 'some text';

그렇다면, never는 언제 사용할까요? 많은 사용 방법이 있겠지만 여기서는 일반적으로는 함수가 어떠한 값도 반환하지 않을 때와 타입 추론 예외를 제거하는 방법을 소개합니다.

먼저, 아래와 같이 어떠한 값도 반환하지 않는 함수라면 반환 타입을 never로 명시하여 어떠한 값도 반환하지 않음을 알려줄 수 있습니다.

const fetchFriendsOfUser = (username: string): never => {
  throw new Error('Not Implemented');
}
const fetchFriendsOfUser = (username: string): never => {
  throw new Error('Not Implemented');
}

never를 사용하여 특정 타입을 할당받지 않도록 하는것도 가능합니다. 예를 들어 아래의 NonString 타입은 어떤 타입이든 될 수 있지만 string 타입인 경우는 never로 추론하여 string 타입의 값이 할당되지 못하도록 할 수 있습니다.

type NonString<T> = T extends string ? never : T;
type NonString<T> = T extends string ? never : T;