TypeScript: 함수(Function)

이전 글 - TypeScript: 인터페이스(Interface)

함수 타입 선언

전에 한 번 다룬대로, TypeScript는 함수에도 타입을 선언할 수 있다. 타입 지정 대상은 함수로 전달되는 매개변수와 최종 리턴 값이 될 수 있다. 타입은 다음과 같이 선언한다.

1
2
3
function split(str: string): string[] {
return str.split('');
}

매개변수는 식별자 뒤에 쌍따옴표 표기를 하고 타입을 선언하면 되고, 리턴 타입은 매개변수 선언 다음에 하면 된다.

함수를 할당할 변수에도 타입 선언이 가능하다.

1
let myFn: Function = function() { };

기본적으로 TypeScript에는 Function 이라는 인터페이스가 내장되어있기 때문에 위와 같이 선언만 해도 함수외에 다른 값은 할당할 수 없다. 그러나 위처럼 함수 인터페이스를 선언하면 매개변수와 리턴 값에 상관없이 어떤 함수라도 할당할 수 있기 때문에 조금 더 Strict한 함수 인터페이스를 선언 하기에는 부족한 면이 있다. 따라서 TypeScript에서는 이런 변수에도 할당될 함수의 인터페이스를 선언할 수 있다.

1
2
let myFn: (string) => string[] = split;
myFn = function() { }; // Type '() => void' is not assignable to type '(string: any) => string[]'. Type 'void' is not assignable to type 'string[]'.

당연하지만 기존에 선언했던 함수의 인터페이스와 실제 할당된 함수의 인터페이스가 다르다면 타입 에러가 뜬다.

함수 오버로딩(Function overloading)

JavaScript에서는 기본적으로 모든 함수가 가변적으로 인자를 받을 수 있었다. 기본적으로 3개의 인자를 받아야하는 함수일지라도, 아무것도 넘기지 않고도 타입 에러 없이 함수를 호출하는 것이 가능했다. 물론 내부 로직에서 레퍼런스 에러가 뜰 수 있겠지만.

어쨌든 TypeScript에서는 모든 함수가 가변인자를 받지는 못한다. 기본적으로는 딱 함수를 선언할 때 명시한 만큼의 인자를 받아야만 타입 에러를 피할 수 있다.

1
2
3
4
5
6
7
function add(a, b) { // `noImplicitAny`를 `false`로 설정한다면, 꼭 타입 선언을 해야만 하는 건 아님.
return a + b;
}

add(1); // Error
add(1, 2, 3); // Error
add(1, 2); // OK

하지만 이렇게 정해진 숫자의 인자만 받을 수 있다면 많은 프로그래밍 요구사항을 해결하기가 어려워 진다. JavaScript에서는 흔히 Optional한 동작을 하는 함수의 경우 부가적인 매개변수를 요구하기도 한다. 예를 들면, Deep copy 옵션을 줄 수도 있는, jQuery의 extend 메소드 같은 경우. 이 때, 함수 오버로딩(Function overloading)을 통해 전달받는 매개변수의 숫자에 따라서 다른 루틴을 타게 만들어서 이런 요구사항을 만족시킬 수가 있다.

C나 Java같은 정적 타입 언어의 경우, 매개변수의 갯수와 타입에 따라서 정적으로 여러 개의 함수를 구현할 수 있다. JavaScript는 이미 알고있겠지만, 하나의 함수 내부에서 타입 검사를 해서 동적으로 오버로딩을 구현할 수 있다. TypeScript의 경우, 오버로딩이 불가능하지는 않지만 그 방식은 정적 타입 언어인 C나 Java보다는 동적 타입언어인 JavaScript와 유사하다. 다른 인자를 받는 여러 개의 함수를 구현하는 것이 아니라, 하나의 함수에서 인자의 타입이나 갯수에 따라 여러 분기를 태우는 것이다.

일단 TypeScript는 함수에 정해진 타입과 정해진 수의 인자를 넘기지 않으면 에러를 발생시키므로 함수를 선언할 때 어느정도의 유연성을 확보해야 오버로딩을 구현할 수 있다. 인자 갯수의 유연성을 확보하는 것이 바로 Optional Parameter다.

Optional Parameter

Optional Paramter는 말 그대로 선택적 매개변수. 즉, 호출할 때 끼워줘도 되고 안 끼워줘도 되는 매개변수이다. 두 경우 모두 에러없이 호출할 수 있으므로 함수 오버로딩에 필수적이다.

1
2
3
4
5
6
7
8
9
10
11
function extend(target: any, deep?: boolean) {
if (deep) {
// ...
} else {
// ...
}
}

extend({ foo: 'bar' }); // OK
extend({ foo: 'bar' }, true); // OK
extend({ foo: 'bar' }, true, 1); // Error

?가 붙은 매개변수에 주목하자. Optional Parameter가 의미하는 그대로, ?가 붙은 매개변수는 넣어도 안 넣어도 함수의 호출에는 문제가 없다. 덕분에 함수 내부에서는 해당 매개변수의 유무를 검사함으로써 다른 동작을 수행할 수 있다.

Union Type

이제 인자의 갯수에 따라 다른 동작을 시킬 수 있게 되었다. 하지만 인자의 타입에 따라 다른 동작을 수행시키고 싶다면 어떻게 해야 할까? 주로 생성자에서 이런 요구가 종종 발생하곤 한다. 다음 예제를 보자.

1
2
3
4
5
6
7
8
9
class Person {
age: number;
constructor(age: number) {
this.age = age;
}
constructor(person: Person) { // Multiple constructor implementations are not allowed. constructor Person(age: number): Person
this.age = person.age;
}
}

위 처럼 생성자에 다른 타입의 매개변수를 넘기는 경우 다른 동작을 호출할 수 있도록 구현하고 싶지만 TypeScript에서는 중복된 식별자를 가진 메소드 구현을 지원하지 않으므로 에러가 발생한다.

이런 경우는 Union Type을 사용하면 해결할 수 있다. Union Type은 ‘Union’이 의미하는 합집합, 즉 OR에 가깝다.

1
2
3
4
5
6
7
8
9
10
class Person {
age: number;
constructor(arg: number | Person) {
if (typeof arg === 'number') {
this.age = arg;
} else if (arg instanceof Person) {
this.age = arg.age;
}
}
}

타입 정의 사이에 껴있는 | 연산자가 보이는가? 쉽게 예상할 수 있겠지만 해당 연산자는 OR를 의미하며, 따라서 arg라는 매개변수는 number 타입 혹은 Person 타입이 될 수 있다. 생성자 내부에서 타입 검사를 해서 오버로딩을 할 수 있을 것이다. 나머지 타입을 통해 호출하게 되면 에러를 발생 시킨다.

그러나 이 방법은 여러모로 구린 해결책이라고 생각한다. 그 이유는 첫 번째, 원래 컴파일러에게 위임했던 타입 검사를 다시 런타임에서 해야한다. 그게 뭐 대수냐 할 수 있겠는데, 결국 TypeScript를 사용하는 이유 중 가장 큰 게 컴파일 타임의 타입 체크 아닌가? TypeScript 자체의 사용 의미가 퇴색될 수 있다. 두 번째, 매개변수의 식별자를 arg처럼 의미없는 이름으로 짓게 된다. 왜냐하면 어떤 타입이 들어오는지 알기 어렵기 때문에 결국 그 매개변수가 가진 의미도 모를 수 밖에 없기 때문이다. 세 번째, 조건문 반복으로 코드의 깔끔함이 저하된다. 네 번째, 함수 내부에서 명시적인 형변환이 필요하다. 마지막을 제외하고는 JavaScript에서 오버로딩을 구현할 때 생기는 단점이고, 결국, JavaScript로 구현한 것과 별반 차이가 없게 된다. (컴파일까지 해야하는데도!)

Java 등의 정적 타입 언어에 익숙한 프로그래머가 아니더라도, 이쯤 되면 대체 왜 TypeScript의 함수 오버로딩이 이따구로 생겨먹게 되는지 의문을 품게 된다. (내가 그랬다) 이유는 간단하다. TypeScript가 정적 타입 언어라고 한들, 결국 동적 타입 언어인 JavaScript로 컴파일 될 것이기 때문이다. TypeScript에서는 타입에 따라 다른 함수로 분류하더라도, JavaScript로 컴파일 되면서 식별자가 같으므로 다른 종류의 인자를 받는 동일한 함수가 되기 때문에 뒤에 구현된 함수가 앞에 구현된 함수를 덮어쓰게 될 것이다. 물론, TypeScript는 인자의 갯수나 타입이 다른 경우에도 동일한 식별자를 가진 메소드를 허용하지 않으므로 컴파일 자체가 불가능하지만, 애초에 이유를 따져보자면 그랬을 것이라는 것이다. 이 링크에서 잘 설명하고 있기 때문에 참조를 추천한다.

뭐 어쨌든, TypeScript에서는 공식적으로 이 방법으로 함수를 오버로딩할 수 있다고 안내하고 있기 때문에, 다른 방법이 없는 현재로선 이렇게라도 써야할 듯 하다.

Default Parameter

Default Paramter는 기본 매개변수. 이 쪽은 이미 ES2015에 도입된 바 있다. JavaScript에서 매개변수로 넘겨받는 값의 디폴트 값을 설정해주고 싶었다면 보통 다음과 같이 할 것이다.

1
2
3
4
function doSomething(whatever) {
whatever = whatever || 'whatever'; // Set default value
// ...
}

TypeScript와 ES2015에서는 이렇게 한다.

1
2
3
function doSomething(whatever = 'whatever') { // Set default value
// ...
}

TypeScript에서는 Default 값을 주는 경우에는 타입을 선언하지 않아도 인자의 타입을 추론하므로 선언을 생략해도 무방하다.

Rest Parameter

Rest Parameter는 흔히 가변인자라고 표현한다. ES5까지의 JavaScript에서는 arguments라는 글로벌 변수를 통해 접근할 수 있었다. this와 마찬가지로 글로벌 변수이면서도 컨텍스트에 따라 값이 달라지기 때문에, 좋은 방법이 아니라는 사실은 이미 알고 있을 것이다. ES2015부터는 ...이라는 표기법을 통해 추가적인 인자들에 접근할 수 있게 되었다. 물론 ES2015를 확장한 TypeScript도 마찬가지다.

Rest Parameter를 통해 인자의 갯수가 추가로 몇 개가 들어오던지 처리할 수 있다. 함수 오버로딩에도 사용할 수 있지만 일반적으로는 함수 오버로딩을 하기위해 Rest Paramter를 사용하지는 않을 것이다. ES2015 스펙이므로 깊게 다루지 않고 넘어가겠다.

noImplicitThis

JavaScript에서 call 혹은 apply, bind 등을 이용해서 this에 원하는 값을 바인딩하는 패턴은 잘 알려져있고 실제로도 자주 사용되는 방법이기도 하다. 지금까지 TypeScript에서 이런 패턴을 사용하는 데에는 약간의 문제가 있었는데, 바로 함수 내부에서 this의 타입을 알 수가 없다는 점이었다.

TypeScript는 2.0 버전부터 이를 다루기 위해서 noImplicitThis라는 옵션을 제공하고 있다. 이 옵션을 사용하면 함수 내부에서 this를 다뤄야 할 경우, 매개변수 목록에 this와 그 타입을 선언해야 한다. 예를 들어, 아래와 같은 함수가 있다고 가정하자.

1
2
3
4
5
6
7
function logArray() {
for (const item of this) {
console.log(item);
}
}

logArray.call(['팔랑이', '쭈쭈']);

JavaScript에서는 문제없는 함수다. 하지만 TypeScript에서 noImplicitThistrue로 설정하고 컴파일하면 'this' implicitly has type 'any' because it does not have a type annotation. 라며 오류가 발생한다. 함수 내부에서 this가 어떤 값이 될지 모르니 any 타입이라고 가정하게 되는 것이다. 이 경우 타입 선언은 아래와 같이 할 수 있다.

1
2
3
4
5
6
7
function logArray(this: string[]) {
for (const item of this) {
console.log(item);
}
}

logArray.call(['팔랑이', '쭈쭈']); // OK

원래 매개변수가 없었음에도 위와 같이 this라는 매개변수를 선언해 줄 수 있다. 참고로 JavaScript에서 this를 매개변수에 넣어서 선언하면 Unexpected Token에러가 발생한다. 즉, 저 구문은 TypeScript에서 타이핑을 할 때만 쓰이고, 컴파일되면서 삭제되는 구문이다. 당연하지만 call이든 apply든 써서 this값을 바인딩 할 때, 선언한 타입과 맞지 않는 값을 넣으면 에러가 발생한다.

클래스에서는 당연히 this의 사용빈도가 매우 높기에 저 옵션을 준 뒤 this를 선언하지 않아도 에러가 나지는 않는다. 하지만 동시에, this를 선언해도 에러가 나지 않는다. 오히려, 클래스에서도 this 선언을 권고하는 것도 괜찮은 방법이라고 생각한다. 마치 Python에서 self가 항상 클래스 메소드의 첫 번째 매개변수가 되는 것처럼.(명시적인 것이 묵시적인 것보다 낫다) 그러나 constructor에서는 this 선언 시 에러가 발생하기 때문에 애매한 부분이 있다.

다음 글 - TypeScript: 제네릭(Generic)