함수 오버로딩
하나의 구현 위에 여러 호출 시그니처를 선언해 인수에 따라 돌려주는 타입을 다르게 알려 주는 방법과, 컴파일러가 시그니처를 고르는 순서, 오버로드가 구현의 동작까지 보장하지는 않는다는 점, 유니온 매개변수와 비교해 고르는 기준을 다룹니다.
어떤 함수는 받는 인수의 타입에 따라 돌려주는 값의 타입이 달라집니다. 문자열을 넣으면 문자열을, 숫자를 넣으면 숫자를 돌려주는 함수가 그런 예입니다. 매개변수와 반환 타입을 모두 string | number로 적으면 검사는 통과하지만, 호출한 쪽에서는 문자열을 넣었는데도 결과가 숫자일 수 있다고 생각해야 합니다.
자바스크립트에는 인수의 타입에 따라 서로 다른 함수를 고르는 기능이 없고, 같은 이름의 함수를 다시 선언하면 앞의 것이 가려지거나 문법 오류가 됩니다. 타입스크립트의 함수 오버로딩(function overloading)은 실행되는 구현은 하나로 두고, 호출하는 방법만 여러 개의 시그니처로 알려 주는 기능입니다. 예제는 ts-first 프로젝트에 overloads.ts를 만들고 include를 그 파일로 바꾸어 확인합니다.
오버로드 시그니처와 구현
오버로딩은 본문이 없는 오버로드 시그니처(overload signature) 여러 개와, 본문이 있는 구현 시그니처(implementation signature) 하나로 이루어집니다. 오버로드 시그니처는 호출하는 쪽에 공개하는 사용법이고, 구현 시그니처는 그 사용법을 모두 받아 처리할 수 있도록 더 넓게 적은 실제 함수입니다.
function getLength(input: string): number;
function getLength(input: string[]): number;
function getLength(input: string | string[]): number {
return input.length;
}
console.log(getLength("Hello TypeScript"));
console.log(getLength(["apple", "banana"]));16
2첫 두 줄이 오버로드 시그니처이고, 셋째 선언이 구현입니다. 문자열과 문자열 배열은 모두 length 속성이 있으므로 구현은 한 줄로 충분하며, 문자열 "Hello TypeScript"의 길이 16과 배열의 요소 개수 2가 출력됩니다. 컴파일한 자바스크립트에는 오버로드 시그니처가 모두 지워지고 구현 하나만 남습니다.
TYPE / RUNTIME / CONTRACT
공개 오버로드로 검사하고 하나의 구현을 실행합니다
getLength는 string과 string[] 호출을 각각 공개합니다. 구현의 더 넓은 매개변수 타입은 호출자가 선택할 추가 후보가 아닙니다.
PUBLIC CALL
유니온 인수가 자동 분배되지는 않습니다
string | string[] 변수는 어느 공개 후보에도 그대로 맞지 않아 오류입니다. 호출 전에 좁히거나 유니온 공개 시그니처를 설계해야 합니다.
IMPLEMENTATION
모든 공개 계약과 호환
구현은 유니온·unknown·제네릭 등으로 각 후보를 처리할 수 있어야 합니다. 타입 선언이 런타임 분기를 자동 생성하지는 않습니다.
RELATION
입력에 따른 반환 타입
processOverload("abc")는 string, 숫자 호출은 number입니다. 본문의 유니온 반환 함수는 두 호출 모두 string | number입니다.
CHOICE
필요한 관계를 간결하게 표현
반환이 같은 getLength는 유니온 매개변수도 가능합니다. 구체적인 후보 우선 배치는 유용하지만 단일 문자열 리터럴 후보 같은 선택 규칙도 있어 단순히 첫 줄만 고른다고 가정하지 않습니다.
오버로드 선언은 검사 시점의 계약입니다. JavaScript에는 실제 구현 함수 하나만 남습니다. 넓은 string 입력의 실제 결과가 문자열일 수도 있는 processInputSafe는 본문에서 string | number 계약으로 따로 보완합니다.
위 그림처럼 각 호출은 공개된 시그니처로 검사받고, 실행은 하나의 구현에서 일어납니다. 중요한 점은 구현 시그니처가 호출하는 쪽에서 보이지 않는다는 것입니다. 구현의 매개변수 타입이 string | string[]이라도, 호출할 때 고를 수 있는 후보는 공개된 두 시그니처뿐입니다.
getLength(123);error TS2769: No overload matches this call.
Overload 1 of 2, '(input: string): number', gave the following error.
Argument of type 'number' is not assignable to parameter of type 'string'.
Overload 2 of 2, '(input: string[]): number', gave the following error.
Argument of type 'number' is not assignable to parameter of type 'string[]'.TS2769는 어떤 오버로드에도 맞지 않는다는 진단이며, 후보마다 맞지 않는 이유를 함께 보여 줍니다. 같은 이유로, 타입이 string | string[]인 변수를 넘기는 호출도 진단됩니다. 그 값은 문자열 하나만 받는 후보에도, 배열 하나만 받는 후보에도 그대로 맞지 않고, 컴파일러가 유니온을 두 후보로 나누어 각각 검사해 주지는 않기 때문입니다.
오버로딩의 규칙
구현 시그니처는 모든 오버로드 시그니처와 호환되어야 합니다. 구현이 어떤 오버로드의 인수를 받을 수 없으면, 그 오버로드에서 진단이 나옵니다.
function doubleValue(x: number): number;
function doubleValue(x: string): string;
function doubleValue(x: number | string): number | string {
if (typeof x === "number") return x * 2;
return x + x;
}
function invalidOverload(x: number): number;
function invalidOverload(x: string): string;
function invalidOverload(x: number): number {
return x * 2;
}error TS2394: This overload signature is not compatible with its implementation signature.doubleValue의 구현은 숫자와 문자열을 모두 받으므로 두 오버로드와 호환됩니다. invalidOverload의 구현은 숫자만 받으므로, 문자열을 받는 두 번째 오버로드에서 TS2394가 나옵니다. 구현이 모든 경우를 받게 하는 방법이 유니온만 있는 것은 아니어서, unknown이나 제네릭으로 넓게 적을 수도 있습니다.
컴파일러는 호출에 맞는 오버로드를 대개 선언한 순서대로 찾아, 처음 맞는 것을 고릅니다. 그래서 겹치는 후보가 있으면 더 구체적인 시그니처를 먼저 적는 것이 기본입니다. 다만 매개변수의 타입이 'hello'처럼 리터럴 타입 하나인 시그니처는 선언한 순서와 관계없이 먼저 검토되므로, 순서만 보고 결과를 짐작하기보다 편집기에서 호출 결과의 타입을 확인합니다.
function processInput(input: "hello"): string;
function processInput(input: string): number;
function processInput(input: string): string | number {
if (input === "hello") {
return "Special Hello!";
}
return input.length;
}
console.log(processInput("hello"));
console.log(processInput("world"));Special Hello!
5processInput("hello")는 리터럴 타입 "hello"의 시그니처에 맞아 결과의 타입이 string이고, processInput("world")는 일반 string의 시그니처에 맞아 number입니다. 실행 결과도 두 타입과 일치합니다.
오버로드가 보장하지 않는 것
오버로드 시그니처는 호출한 쪽에 알려 줄 타입을 정할 뿐, 구현이 정말 그 타입을 돌려주는지는 검사하지 않습니다. 위의 processInput에 넓은 타입의 string 변수를 넘기면 그 틈이 드러납니다.
const word: string = "hello";
const answer = processInput(word);
console.log(typeof answer);stringword의 타입은 string이므로 컴파일러는 두 번째 시그니처를 골라 answer의 타입을 number라고 알려 줍니다. 하지만 실제 값이 "hello"이므로 구현은 문자열 "Special Hello!"를 돌려주고, typeof는 string을 출력합니다. 이 상태에서 answer.toFixed(0)를 쓰면 검사를 통과하지만 실행 중에 멈추므로, 오버로드가 약속하는 타입과 구현의 분기가 정말 일치하는지는 따로 확인해야 합니다.
넓은 string을 받는 시그니처가 문자열을 돌려줄 수도 있다면, 그 가능성을 반환 타입에 그대로 적는 것이 안전합니다.
function processInputSafe(input: "hello"): string;
function processInputSafe(input: string): string | number;
function processInputSafe(input: string): string | number {
return input === "hello" ? "Special Hello!" : input.length;
}
const safeResult = processInputSafe(word);
if (typeof safeResult === "number") {
console.log(safeResult.toFixed(0));
} else {
console.log(safeResult.toUpperCase());
}SPECIAL HELLO!이제 넓은 string으로 부른 결과는 string | number이므로, 좁힌 뒤에만 각 타입의 메서드를 쓸 수 있습니다. 공개 시그니처와 하나의 구현을 구분하는 설명은 공식 Handbook의 오버로드에 있습니다.
유니온 매개변수와 비교하기
같은 문제를 유니온 매개변수 하나로 풀 수도 있습니다. 두 방법의 차이는 호출한 쪽이 받는 반환 타입에서 드러납니다.
function processUnion(input: string | number): string | number {
if (typeof input === "string") {
return input.toUpperCase();
}
return input * 2;
}
function processOverload(input: string): string;
function processOverload(input: number): number;
function processOverload(input: string | number): string | number {
if (typeof input === "string") {
return input.toUpperCase();
}
return input * 2;
}
const res1 = processUnion("abc");
const res2 = processOverload("abc");
const res3 = processOverload(10);
console.log(res1, res2, res3);ABC ABC 20실행 결과는 같지만, 편집기에서 보면 res1의 타입은 string | number이고 res2는 string, res3은 number입니다. 유니온 매개변수는 어떤 인수를 넣든 같은 반환 타입을 알려 주고, 오버로드는 인수의 타입에 맞추어 반환 타입을 좁혀 알려 줍니다.
그래서 오버로드는 인수의 타입에 따라 반환 타입이 분명히 달라지고, 호출한 쪽이 그 정확한 타입을 알아야 할 때 씁니다. 반대로 반환 타입이 인수에 따라 달라지지 않는다면 유니온 매개변수가 더 간단하며, 공식 Handbook도 가능하면 오버로드보다 유니온 매개변수를 쓰라고 권합니다. 유니온 매개변수는 앞에서 본 것처럼 string | string[] 타입의 변수도 그대로 받을 수 있다는 장점이 있습니다.
정리
- 오버로딩은 본문이 없는 오버로드 시그니처 여러 개와 본문이 있는 구현 하나로 이루어지며, 컴파일한 자바스크립트에는 구현만 남습니다.
- 호출하는 쪽에서는 오버로드 시그니처만 보이고 구현 시그니처는 보이지 않으므로, 어느 후보에도 맞지 않는 호출은 TS2769로 진단됩니다.
- 구현은 모든 오버로드와 호환되어야 하며, 그렇지 않은 오버로드에서는 TS2394가 나옵니다.
- 컴파일러는 대개 선언한 순서대로 처음 맞는 오버로드를 고르지만, 리터럴 타입 하나를 받는 시그니처는 먼저 검토됩니다.
- 오버로드는 구현이 약속한 타입을 정말 돌려주는지 검사하지 않으므로, 반환 타입이 인수에 따라 달라지지 않는다면 유니온 매개변수를 먼저 고려합니다.
연습 문제
1. 유니온 타입의 인수
const value: string | string[] = Math.random() > 0.5 ? "abc" : ["a"];인 value를 getLength(value)로 넘길 수 있나요? 두 경우 모두 길이만 필요하다면 어떻게 선언하는 것이 더 간단한가요?
해설 보기
넘길 수 없습니다. value는 문자열 하나만 받는 후보에도, 배열 하나만 받는 후보에도 그대로 맞지 않아 TS2769가 나옵니다. 두 경우 모두 number를 돌려주므로 오버로드가 필요 없고, function lengthOf(input: string | string[]): number { return input.length; }처럼 유니온 매개변수 하나로 선언하면 이런 변수도 그대로 받을 수 있습니다.
2. 다른 값으로 부른 안전한 버전
word의 값을 "world"로 바꾸어 processInputSafe(word)를 부르면, safeResult의 타입과 출력은 무엇인가요? 좁히지 않고 safeResult.toFixed(0)를 쓰면 어떻게 되나요?
해설 보기
word의 타입은 여전히 string이므로 safeResult의 타입은 string | number이고, 실제 값은 "world"의 길이 5이므로 숫자 분기에서 5가 출력됩니다. 좁히지 않은 safeResult.toFixed(0)는 결과가 문자열일 수도 있으므로 Property 'toFixed' does not exist on type 'string | number'.로 진단됩니다.
3. 순서를 바꾼 오버로드
processInput의 두 오버로드 시그니처의 순서를 바꾸어, (input: string): number를 먼저 적었습니다. processInput("hello")의 결과 타입은 무엇인가요?
해설 보기
결과 타입은 여전히 string입니다. 매개변수의 타입이 리터럴 타입 하나인 시그니처는 선언한 순서와 관계없이 먼저 검토되므로, "hello" 인수는 (input: "hello"): string에 먼저 맞습니다. 이런 예외가 있으므로, 오버로드의 결과는 순서만 보고 짐작하지 말고 편집기에서 호출 결과의 타입을 확인합니다.
다음 글 제네릭 함수에서는 오버로드를 여러 개 쓰는 대신, 인수의 타입을 변수처럼 받아 반환 타입과 이어 주는 제네릭 함수를 다룹니다.