🔥 타입 추론: 언제 쓰고 언제 두는가

773자
8분

let count = 3 한 줄 위에 count: number 호버 툴팁이 떠 있는 inferred 카드와 let count: number = 3 한 줄에 annotated 라벨이 붙은 카드가 위아래로 놓인 표지

let name: string = "Mejoo" 를 치고 엔터를 눌렀을 때는 손이 먼저 갔다. 앞에서 콜론과 타입을 직접 적는 습관을 이미 한 번 만들었기 때문이다. 바로 다음 줄에 let count = 3 을 적을 때는 손이 잠깐 멈췄다. 똑같이 변수 선언인데 이번에는 콜론이 없다. 그래도 에디터에 마우스를 올리니 count: number 가 뜬다. 둘 다 컴파일은 통과한다. 차이는 내가 타입을 적었는가, 아니면 우변을 보고 컴파일러가 타입을 정했는가에 있다. 여기서 기준을 하나 세워 두면 그다음 코드가 한결 단정해진다.

초깃값이 분명하면 타입을 다시 적지 않는다

let total = 3 + 4 / const greeting = "hello" / let user = { id: 1, name: "Mejoo" } 세 줄과 우측에 number / "hello" 리터럴 타입 / { id: number; name: string } 세 추론 결과 카드, 아래에 let → widen / const → narrow 두 배지가 놓인 다이어그램

초깃값이 분명하면 타입 주석은 중복이다. 컴파일러는 우변을 먼저 읽고, 그 값으로 좌변 타입을 잡는다. 예를 들어 숫자 계산은 number, 문자열 리터럴은 문자열 타입으로 잡는다. 객체 리터럴은 프로퍼티 구성을 가진 객체 타입으로 잡는다.

ts
// file: infer-from-init.ts
export {};
 
let total = 3 + 4;
//   ^?
// number
 
const greeting = "안녕";
//   ^?
// "안녕"
 
let user = { id: 1, name: "Mejoo" };
//   ^?
// { id: number; name: string }
ts
// file: infer-from-init.ts
export {};
 
let total = 3 + 4;
//   ^?
// number
 
const greeting = "안녕";
//   ^?
// "안녕"
 
let user = { id: 1, name: "Mejoo" };
//   ^?
// { id: number; name: string }

첫 줄은 계산 결과가 숫자이므로 number 가 된다. 둘째 줄은 const 이므로 "안녕" 자체를 유지한다. 셋째 줄은 객체 리터럴이지만 let 으로 받았기 때문에 각 프로퍼티가 더 넓은 타입으로 바뀐다. id1 이 아니라 number, name"Mejoo" 가 아니라 string 이 된다.

letconst 는 추론 폭이 다르다. let 은 나중에 다른 값을 넣을 수 있으므로 컴파일러가 더 넓은 타입으로 잡는다. const 는 다시 대입할 수 없으므로 더 좁은 타입으로 잡는다. 값 레벨에서는 선언 키워드 한 단어만 다르지만, 에디터에서 마우스를 올리면 추론된 타입이 바로 보인다.

입력은 적고 반환은 둔다

function add(a: number, b: number) { return a + b } 시그니처 카드에서 a: number, b: number 매개변수에 'param annotation — written by hand' 라벨이 위로 가리키고, 함수 위에 (a: number, b: number) => number 추론 카드와 'return type — inferred by compiler' 라벨이 붙은 다이어그램

함수에서는 기준이 조금 달라진다. 매개변수 타입은 내가 적는 편이 낫다. 호출하는 쪽에서 무엇을 넣어야 하는지 바로 알 수 있기 때문이다. 반면 반환 타입은 함수 본문만 봐도 드러나는 경우가 많다. 이때는 같은 정보를 한 번 더 적지 않아도 된다.

ts
// file: annotate-inputs.ts
export {};
 
function add(a: number, b: number) {
  return a + b;
}
//   ^?
// (a: number, b: number) => number
 
console.log(add(3, 4));
ts
// file: annotate-inputs.ts
export {};
 
function add(a: number, b: number) {
  return a + b;
}
//   ^?
// (a: number, b: number) => number
 
console.log(add(3, 4));

이 예제에서 꼭 적어야 하는 부분은 ab 의 타입이다. 두 값은 함수 밖에서 들어온다. 매개변수 타입을 빼면 컴파일러가 호출부에서 확인할 정보가 줄어든다. 반환 타입 numberreturn a + b 한 줄에서 나온다. 그래서 반환 타입을 생략해도 시그니처 정보는 그대로 남는다.

나는 이 구분에서 자주 헷갈렸다. 특히 초반에는 매개변수 타입과 반환 타입을 둘 다 적어야 마음이 놓였다. 그런데 반환 타입까지 매번 적기 시작하면, 본문을 고쳤을 때 시그니처도 같이 손봐야 한다. 본문이 곧 답인 함수라면 그 중복은 금방 귀찮아진다.

추론이 너무 넓으면 내가 다시 좁힌다

let x = "ts" 가 string 으로 widen 되고, const x = "ts" 가 "ts" 그대로, let mode: "light" | "dark" = "light" 가 "light" | "dark" 유니온으로 잡히는 세 패널 비교 다이어그램

추론 결과가 항상 필요한 타입과 맞지는 않는다. 문자열 하나를 넣었는데 컴파일러가 string 으로 넓게 잡을 때가 있고, 그보다 좁은 값 집합을 유지하고 싶을 때도 있다. 그때는 내가 타입을 직접 적어 범위를 좁힌다.

ts
// file: widen-vs-narrow.ts
export {};
 
{
  let x = "ts";
  //   ^?
  // string
}
 
{
  const x = "ts";
  //   ^?
  // "ts"
}
 
let mode: "light" | "dark" = "light";
//   ^?
// "light" | "dark"
 
mode = "dark";
// mode = "system";
ts
// file: widen-vs-narrow.ts
export {};
 
{
  let x = "ts";
  //   ^?
  // string
}
 
{
  const x = "ts";
  //   ^?
  // "ts"
}
 
let mode: "light" | "dark" = "light";
//   ^?
// "light" | "dark"
 
mode = "dark";
// mode = "system";

첫 블록의 xlet 이라서 string 이 된다. 나중에 다른 문자열을 넣을 수 있기 때문이다. 둘째 블록의 xconst 이라서 "ts" 그대로 남는다. 값 하나만 허용해도 충분하다고 컴파일러가 판단한다.

마지막 mode 는 내가 직접 범위를 적었다. 이때 컴파일러는 초기값 "light" 하나만 보지 않고, 앞으로 허용할 값 전체를 기준으로 검사한다. 그래서 "dark" 는 통과하고, 주석을 풀어 mode = "system" 을 넣으면 컴파일러가 그 줄에서 바로 멈춘다. 추론이 너무 넓을 때는 내가 타입을 명시해서 허용 범위를 줄인다.

반환 타입도 같은 기준으로 보면 된다. 본문이 짧고 계산이 단순하면 추론에 두는 편이 낫다. 반대로 본문을 손보는 동안 반환 형태가 함께 바뀌면 안 되는 함수라면, 그때는 반환 타입도 직접 적는다.

디폴트는 분명하다. 매개변수는 적고, 반환 타입은 추론에 둔다. 예외도 분명하다. 추론이 너무 넓거나 반환 형태를 계약처럼 고정해야 하면 그때는 내가 직접 적는다.

YouTube 영상

채널 보기
숫자 하나가 AI 모델의 운명을 바꾼다? | 선형대수학
우리가 매일 쓰는 맞춤법 검사기와 라우터 속에 숨겨진 알고리즘은? | Trie 자료구조 이야기
AI는 데이터를 어떻게 분류할까? 벡터의 거리와 KNN 알고리즘 | 선형대수학
행렬의 기본 연산 - 행렬 덧셈, 스칼라 곱, 전치 | 선형대수학
트라이(Trie)를 이용한 자동 완성 알고리즘 | Trie 자료구조 이야기
AI를 위한 선형대수학 - 소개 | 선형대수학
직교성과 벡터 투영 | 선형대수학
행렬의 가장 중요한 연산 - 행렬 곱셈 | 선형대수학