TypeScript(이하 TS)는 JavaScript(이하 JS)에서 발생할 수 있는 런타임 에러를 줄이기 위해 타입 이론을 도입한 정적 분석 도구입니다. 이러한 타입 시스템은 우리 코드가 더 안정적이고 오류가 적도록 도와줍니다. 하지만 많은 개발자들은 TS를 활용하면서 타입 추론의 깊은 부분에 대해서는 익숙하지 않은 경우가 많죠. 특히 라이브러리 개발을 할 때는 타입 추론이 제대로 이루어지는 것이 필수적입니다. 이번 글에서는 초보 개발자도 이해할 수 있도록 TS의 타입 이론과 타입 추론의 기초부터 고급 내용까지 차근차근 설명해 드리겠습니다.
이 글을 읽으면 좋은 사람
- JavaScript 라이브러리를 개발하고 싶은 개발자
- TypeScript에서 타입이 추론되는 원리에 대해 궁금한 개발자
- infer 키워드와 고급 제네릭을 활용해 보고 싶은 개발자
타입 이론이란?
타입 이론은 20세기 초반에 수학자 앨런조 처치(Alonzo Church)가 람다 대수(lambda calculus)를 연구하면서 생겨났습니다. 타입 이론의 핵심은 우리가 코드에서 어떤 값이 어떤 타입을 가지는지 명확하게 정의하는 것입니다. 타입을 잘 정의하면 컴파일러가 우리 대신 오류를 잡아주기 때문에 안전한 코드를 작성할 수 있습니다.
타입을 간단하게 정의하자면, 어떤 변수에 들어갈 수 있는 값의 집합과 그 값들이 가질 수 있는 속성들입니다. 예를 들어 number 타입은 숫자만을 가질 수 있고, 숫자와 관련된 산술 연산을 할 수 있습니다.
let x: number = 3.141592; // x는 number 타입입니다.
x = 'hello'; // 오류! 'hello'는 number 타입이 아닙니다.
위 예시에서 x는 숫자형 변수로 정의되어 있습니다. 때문에 문자열 값을 할당하려고 하면 TypeScript는 이를 막아줍니다.
타입 이론의 필요성
타입을 정의하는 이유는 코드를 작성하면서 발생할 수 있는 오류를 미리 방지하기 위해서입니다. 예를 들어 JavaScript에서 변수에 문자열과 숫자를 혼합해서 사용하는 경우, 의도치 않은 오류가 발생할 수 있습니다.
let value = "Hello";
value = 42; // 자바스크립트에서는 허용되지만 의도치 않은 버그가 발생할 수 있음
하지만 TypeScript에서는 이러한 타입 혼동을 방지할 수 있습니다. 이를 통해 코드를 더 신뢰성 있게 유지할 수 있습니다.
타입 추론이란 무엇인가?
타입 추론이란 컴파일러가 변수나 함수의 타입을 자동으로 추론하는 기능입니다. JavaScript와 달리 TypeScript는 코드에서 사용하는 값들의 타입을 명시하거나 추론해 안전하게 코드를 작성하도록 돕습니다.
예를 들어 다음과 같은 코드를 살펴볼까요?
let name = "Alice";
여기서 name의 타입을 명시하지 않았지만, TypeScript는 자동으로 string 타입으로 추론합니다. 이처럼 TypeScript는 우리가 명시적으로 타입을 지정하지 않아도 코드의 문맥에서 타입을 추론할 수 있습니다.
함수에서의 타입 추론
함수에서도 타입 추론은 중요한 역할을 합니다. 함수의 반환 타입이나 매개변수 타입을 명시하지 않아도 TypeScript는 이를 추론할 수 있습니다.
function add(a: number, b: number) {
return a + b;
}
위의 코드에서 add 함수의 반환 타입은 명시되어 있지 않지만, 컴파일러는 두 매개변수 a와 b의 타입이 number이므로 반환 타입도 자동으로 number로 추론합니다.
TypeScript에서의 타입 비교
타입 추론을 제대로 이해하려면 TypeScript에서 타입 간의 포함 관계를 이해해야 합니다. 예를 들어 어떤 타입이 다른 타입의 "슈퍼타입"인지 아니면 "서브타입"인지 이해하는 것이 중요합니다.
간단히 설명하자면, 타입 A가 타입 B의 서브타입이라는 것은 B가 가지고 있는 모든 속성을 A도 가지고 있음을 의미합니다.
type A = { x: number };
type B = { x: number; y: string };
위의 코드에서 B는 A의 서브타입입니다. B에는 x와 y라는 두 개의 속성이 있고, A에는 x만 있으므로 A가 B의 속성을 모두 포함하지는 않습니다. 하지만 B는 A의 모든 속성을 포함하고 있으므로 B는 A의 서브타입이라고 할 수 있습니다.
서브타입과 슈퍼타입 예시
좀 더 쉽게 이해할 수 있도록 실제 예시를 추가해보겠습니다.
type Animal = { name: string };
type Dog = { name: string; breed: string };
let myDog: Dog = { name: "Buddy", breed: "Golden Retriever" };
let myAnimal: Animal = myDog; // OK, Dog는 Animal의 서브타입이므로 대입 가능
myDog = myAnimal; // 오류! Animal은 Dog의 모든 속성을 포함하지 않음
위 예시에서 Dog는 Animal의 서브타입이기 때문에 myDog를 myAnimal에 대입할 수 있지만, 그 반대는 불가능합니다. 이는 Dog가 Animal에 비해 더 많은 정보를 담고 있기 때문입니다.
타입의 부분순서 집합
타입 간에는 비교가 가능한데, 이를 통해 타입의 포함 관계를 정리할 수 있습니다. 예를 들어 number 타입과 특정 리터럴 타입인 42는 비교 가능하며, 42는 number의 서브타입입니다.
let n: number;
n = 42; // OK
위의 코드에서 42는 number 타입의 서브타입이므로 n에 값을 할당할 수 있습니다.
하지만 모든 타입이 서로 비교 가능한 것은 아닙니다. 예를 들어 string 타입과 { x: number } 같은 객체 타입은 서로 비교할 수 없습니다. 이런 타입들을 부분순서집합(partially ordered set)이라고 부릅니다.
타입 대입의 원리
TypeScript에서는 타입 A가 타입 B의 서브타입이라면, B 타입의 값이 A 타입의 변수에 대입될 수 있습니다.
const num: number = 42; // number 타입에 리터럴 42를 대입할 수 있음
const str: string = 42; // 오류! string 타입에 number를 대입할 수 없음
number 타입은 그 자체로도 서브타입입니다. 그래서 42라는 숫자 리터럴은 number 타입에 할당될 수 있습니다. 반면 string 타입은 숫자와는 관련이 없으므로 할당이 불가능합니다.
유니언 타입 예시
유니언 타입은 여러 타입을 하나로 합쳐주는 역할을 합니다. 예를 들어 string | number 타입은 문자열 또는 숫자 값을 가질 수 있습니다.
let value: string | number;
value = "Hello"; // OK
value = 100; // OK
value = true; // 오류! boolean 타입은 허용되지 않음
위 예시에서 value 변수는 string이나 number 타입의 값을 가질 수 있지만, boolean 타입의 값은 할당할 수 없습니다.
특수 타입 다루기: never와 unknown
TypeScript에는 특별한 몇 가지 타입이 있습니다. 바로 never와 unknown입니다. 이들은 타입 추론과 타입 대입에서 중요한 역할을 합니다.
never
never는 존재할 수 없는 값을 나타냅니다. 예를 들어, 항상 오류를 던지는 함수의 반환 타입이 never입니다.
function error(message: string): never {
throw new Error(message);
}
위 함수는 항상 오류를 발생시키므로, 정상적으로 반환되지 않습니다. 때문에 이 함수의 반환 타입은 never입니다.
never 타입은 모든 타입의 서브타입이므로, 어디에든 할당될 수 있습니다. 그러나 그 어떤 타입도 never에 할당될 수는 없습니다.
unknown
unknown은 모든 타입을 포함할 수 있는 가장 넓은 타입입니다. 즉, 어떤 값이라도 unknown 타입에 대입될 수 있지만, unknown 타입은 바로 다른 타입에 대입될 수 없습니다.
let anything: unknown;
anything = 10; // OK
anything = "hello"; // OK
let num: number = anything; // 오류! unknown 타입은 직접적으로 number에 할당할 수 없음
unknown은 안전성을 위해 다른 타입으로 사용하기 전에 반드시 타입 검사를 거쳐야 합니다.
if (typeof anything === "number") {
let num: number = anything; // 타입 검사 후에는 안전하게 할당 가능
}
함수 타입에서의 공변성과 반변성
함수의 타입 추론에서 중요한 개념이 **공변성(covariance)**과 **반변성(contravariance)**입니다.
- 공변성: 반환값의 타입이 더 작은 타입이라면 해당 함수는 상위 타입의 함수로 사용할 수 있습니다.
- 반변성: 함수의 매개변수 타입은 반대로 생각해야 합니다. 즉, 매개변수 타입이 더 큰 범위여야 더 작은 범위의 함수로 치환 가능합니다.
예를 들어보겠습니다.
type A = (value: string) => void;
type B = (value: "hello") => void;
let funcA: A;
let funcB: B;
funcA = funcB; // OK, B의 매개변수가 A보다 좁기 때문에 대입 가능
funcB = funcA; // 오류! A의 매개변수 타입은 B보다 넓기 때문에 대입 불가능
위 코드에서 funcB는 매개변수로 문자열 리터럴 "hello"만 받을 수 있는 좁은 타입이기 때문에 funcA로 대입할 수 있습니다. 반면, funcA는 모든 문자열을 받을 수 있으므로 "hello"만 받을 수 있는 funcB에 대입하는 것은 안전하지 않습니다.
인자의 개수에 따른 함수 타입 비교
함수의 인자 개수도 타입 비교에 영향을 줍니다. 인자가 적은 함수는 인자가 많은 함수보다 더 범용적으로 사용될 수 있습니다.
function greet(): void {
console.log("Hello!");
}
function greetWithName(name: string): void {
console.log(`Hello, ${name}!`);
}
let simpleGreet: () => void;
let detailedGreet: (name: string) => void;
simpleGreet = greetWithName; // 오류! 인자가 필요한 함수는 인자가 없는 함수로 대체 불가
detailedGreet = greet; // OK, 인자가 없는 함수는 인자가 필요한 함수로 대체 가능
결론
TypeScript의 타입 추론은 코드의 안정성을 보장하는 중요한 도구입니다. 특히 타입 시스템을 이해하고 올바르게 활용하면 라이브러리 개발이나 협업 과정에서 오류를 미연에 방지할 수 있습니다. 이번 글에서 다룬 공변성, 반변성, 특수 타입의 개념은 처음에는 다소 어려울 수 있지만, 예시 코드를 직접 작성해 보며 연습하면 점차 익숙해질 것입니다.
타입 시스템을 마스터하는 것은 복잡한 작업처럼 보일 수 있지만, 올바른 타입을 이해하고 활용하는 것은 안전하고 효율적인 코드를 작성하는 데 큰 도움이 됩니다. 앞으로 TypeScript에서 infer와 never 같은 고급 제네릭을 사용할 때, 이 개념들이 도움이 되길 바랍니다.
댓글