📘
til ) TypeScript 복습하기 04
June 04, 2024
More on Functions
- TypeScript에서는 함수들이 호출될 수 있는 방법을 서술하는 방법이 많이 있다. 함수를 설명하는 타입들을 작성하는 방법들을 알아본다.
함수 타입 표현식
- 함수를 설명하는 가장 간단한 방법은 함수 타입 표현식이다.
function greeter(fn: (a: string) => void) {
fn("Hello, World");
}
function printToConsole(s: string) {
console.log(s);
}
greeter(printToConsole);
(a: string) => void
라는 문법은 “문자열 타입a
를 하나의 매개변수로 가지고 반환값이 없는 함수”를 의미한다. 함수 선언처럼, 매개변수의 타입이 지정되지 않으면, 암묵적으로any
가 된다.- 매개변수 이름이 필수다. 즉, a라고 이름을 붙이는 것은 필수라고 생각해라.
- 타입 별칭을 사용해서 함수의 타입에 이름을 붙이는 것도 가능하다.
type GreetFunction = (a: string) => void;
function greeter(fn: GreetFunction) {
// ...
}
호출 시그니처
- 함수들은 호출만 되는게 아니라 프로퍼티를 가질 수 도 있다. 단, 함수 타입 표현식 문법은 프로퍼티를 정의하는 것을 허락하지 않는다.
- 객체 타입에 호출 시그니처를 활용해야만 함수에 프로퍼티를 추가할 수 있다.
type DescribableFunction = {
description: string;
(someArg: number): boolean;
};
function doSomething(fn: DescribableFunction) {
console.log(fn.description + " returned " + fn(6));
}
- 함수 타입 표현식이 아니므로 반환타입을 표현할 때
=>
가 아닌:
로 표현한다.
구성 시그니처
- 호출 시그니처 앞에
new
키워드를 붙임으로써 구성 시그니처를 작성할 수 있다.
type SomeConstructor = {
new (s: string): SomeObject;
};
function fn(ctor: SomeConstructor) {
return new ctor("hello");
}
- JS의 Date와 같은 일부 객체는 new가 있든 없든 호출이 된다. 더불어 호출 시그니처와 구성 시그니처를 임의로 같은 타입에서 결합시키는 것도 가능하다.
interface CallOrConstruct {
new (s: string): Date;
(n?: number): number;
}
제네릭 함수
- 제네릭의 키워드가 이야기 하듯, 제네릭 문법을 통해 선언함으로써 any가 필요한 상황을 만들지 않고 적절하게 매개변수 및 반환을 선언하는 것이 가능하다.
// 제네릭 안쓴 방식
function firstElement(arr: any[]) {
return arr[0];
}
// 제네릭 방식
function firstElement<Type>(arr: Type[]): Type | undefined {
return arr[0];
}
Type
이란 키워드로 선언하고, 필요한 곳에 넣어서 적절하게 함수를 선언이 가능했고, 이렇게 하면 다음의 케이스들에 대응이 가능하다.
// s는 "string" 타입
const s = firstElement(["a", "b", "c"]);
// n은 "number" 타입
const n = firstElement([1, 2, 3]);
// u는 "undefined" 타입
const u = firstElement([]);
추론(Inference)
- type을 특정하지 않아도 추론이 될 수 있다. TS 가 자동으로 선택한 것이다.
function map<Input, Output>(arr: Input[], func: (arg: Input) => Output): Output[] {
return arr.map(func);
}
// 매개변수 'n'의 타입은 'string' 입니다.
// 'parsed'는 number[] 타입을 하고 있습니다.
const parsed = map(["1", "2", "3"], (n) => parseInt(n));
타입 제한 조건
- 제한적으로 두 값을 연관 시키길 원할 때도 있으나, 특정한 값들의 부분 집합에 한하여 동작하기를 원할 때도 있을 수 있다. 이때 타입 제한조건을 사용하여 타임 배개변수가 받아 들일 수 있는 타입을 제한하는 게 가능하다.
function longest<Type extends { length: number }>(a: Type, b: Type) {
if (a.length >= b.length) {
return a;
} else {
return b;
}
}
// longerArray 의 타입은 'number[]' 입니다'
const longerArray = longest([1, 2], [1, 2, 3]);
// longerString 의 타입은 'alice' | 'bob' 입니다.
const longerString = longest("alice", "bob");
// 에러! Number에는 'length' 프로퍼티가 없습니다.
const notOK = longest(10, 100);
// Argument of type 'number' is not assignable to parameter of type '{ length: number; }'.
- 위의 예시는 extends 키워드를 통해 length 프로퍼티의 존재를 찾도록 되어 있으며, 이를 통해 그렇지 않은 변수는 입력 시 에러를 반환하게 만들어낸다.
- 이때 특징이라고 할 수 있는 점은 longest 라는 함수의 반환 타입 역시 추론되어 동작한다는 점이다.
제한된 값으로 작업하기
- 아래의 예시는 제네릭 타입 제약 조건을 사용할 때 생기는 실수의 예시 코드 이다.
function minimumLength<Type extends { length: number }>(
obj: Type,
minimum: number
): Type {
if (obj.length >= minimum) {
return obj;
} else {
return { length: minimum };
//Type '{ length: number; }' is not assignable to type 'Type'.
// '{ length: number; }' is assignable to the constraint of type 'Type', but 'Type' could be instantiated with a different subtype of constraint '{ length: number; }'.
}
}
- 이 함수가 문제 있는 이유는 리턴하는 것이 입력된 어떤 객체를 다시 반환해주기 때문에 문제가 생긴다. 만약 위 예시가 성공적으로 돌아간다면 다음 아래의 예시 코드가 정상 작동하지 않음을 볼 수 있다.
// 'arr' gets value { length: 6 }
const arr = minimumLength([1, 2, 3], 6);
// 여기서 배열은 'slice' 메서드를 가지고 있지만
// 반환된 객체는 그렇지 않기에 에러가 발생합니다!
console.log(arr.slice(0));
타입 인수를 명시하기
- TS는 제네릭 호출에서 의도된 타입을 대체로 추론해 내지만, 꼭 성공하는 것은 아니다.
function combine<Type>(arr1: Type[], arr2: Type[]): Type[] {
return arr1.concat(arr2);
}
// 이러한 형태의 호출은 당연히 안될 것이다.
const arr = combine([1, 2, 3], ["hello"]);
// Type 'string' is not assignable to type 'number'.
// 만약 이를 의도하는 것이라면 수동으로 Type 을 명시하는게 올바른 방식이다.
const arr = combine<string | number>([1, 2, 3], ["hello"]);
좋은 제네릭 함수를 작성하기 위한 가이드라인
- 제네릭 함수는 편리한 만큼 쓰기 용이하지만, 동시에 타입 매개변수나 제한조건을 꼭 필요하지 않는 곳에 사용하는 것은 추론을 잘 하지 못하게 해서 함수 호출자에게 애매하게 만들 수 있다.
- 타입 매개변수를 조아선 안된다.
function firstElement1<Type>(arr: Type[]) { return arr[0]; } function firstElement2<Type extends any[]>(arr: Type) { return arr[0]; } // a: number (good) const a = firstElement1([1, 2, 3]); // b: any (bad) const b = firstElement2([1, 2, 3]);
- 둘은 비슷하나 결론적으로 첫번째 방식이 좋은 방식이다. 가능하다면 타입 매개변수를 제약하기 보다는, 타입 매개변수 자체를 사용하는 것을 권장한다.
- 더 적은 타입 매개변수 사용하기
function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] { return arr.filter(func); } function filter2<Type, Func extends (arg: Type) => boolean>( arr: Type[], func: Func ): Type[] { return arr.filter(func); }
- 위의 경우 호출 대상이 타입 인수를 추가로 제공해줘야 하고, 이러한 형태는 읽고 이해하는데 어렵게 만들 뿐이다. 따라서 타입 매개변수는 최소화 시키는 게 좋다.
- 타입 매개변수는 두 번 나타나야 한다.
- 사실 제네릭을 굳이 쓰지 않아도 될 상황에서 쓰는 것은 안 쓰는 만 못하다.
// 이게 좋은가?
function greet1<Str extends string>(s: Str) {
console.log("Hello, " + s);
}
// 이게 좋은가?
function greet2(s: string) {
console.log("Hello, " + s);
}
greet2("world");
선택적 매개변수
- JS 에서는 함수에 따라 가변적인 숫자의 인자들을 받아들일 수 있다.
function f(n: number) {
console.log(n.toFixed()); // 0 arguments
console.log(n.toFixed(3)); // 1 argument
}
- TS 에서는 여기서
?
를 통해 선택적으로 인자를 만들어버릴 수 있다.
function f(x?: number) {
// ...
}
f(); // OK
f(10); // OK
- 뿐만 아니라 기본값을 지정하는 것도 가능하다.
function f(x = 10) {
// ...
}
- 더불어
undefined
값을 활용하면, 인수를 누락 시키는 방식을 흉내 낼 수도 있고, 이러한 형태는 가독성 면에서 득이 될 수 있다.
declare function f(x?: number): void;
// cut
// All OK
f();
f(10);
f(undefined);
콜백 함수에서의 선택적 매개변수
- 선택적 매개변수 및 함수 타입 표현식에 대해 알고, 콜백을 호출하는 함수를 작성할 때 아래 처럼 실수하는 경우가 발생하기 쉽다.
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
for (let i = 0; i < arr.length; i++) {
callback(arr[i], i);
}
}
- 이 예시를 보면
index?
라는 매개변수는, 보통 의도는 두 호출 모두 유효하기를 바라는 것일 것이다.
myForEach([1, 2, 3], (a) => console.log(a));
myForEach([1, 2, 3], (a, i) => console.log(a, i));
- 그러나 실제로 의미하는 바 콜백이 하나의 호출될 수 있음을 말하는데, 이렇게 되면 myForEach가 내부에서 함수 구현이 아래와 같을 수 있다고 말하는 것이다.
function myForEach(arr: any[], callback: (arg: any, index?: number) => void) {
for (let i = 0; i < arr.length; i++) {
// 오늘은 index를 제공하고 싶지 않아 = = +
callback(arr[i]);
}
}
- 따라서 이러한 가능성을 가지고 있으므로 TS는 실제로 일어나지 않을 에러를 보여주게된다.
myForEach([1, 2, 3], (a, i) => {
console.log(i.toFixed());
// 'i' is possibly 'undefined'.
});
- JS는 지정된 인수보다 많은 인수를 전달하면, 남은 인수를 무시하고, TS 역시 동일하게 동작한다.
함수 오버로드
- 다른 언어들 처럼 다양한 오버로드 시그니처를 작성하여 함수 오버로드를 구현할 수 있다.
- 뿐만 아니라 함수 시그니처 몇개를 여러번 적고 본문을 작성하는 방식으로 오버로드를 구현이 가능하다.
function makeDate(timestamp: number): Date;
function makeDate(m: number, d: number, y: number): Date;
function makeDate(mOrTimestamp: number, d?: number, y?: number): Date {
if (d !== undefined && y !== undefined) {
return new Date(y, mOrTimestamp, d);
} else {
return new Date(mOrTimestamp);
}
}
const d1 = makeDate(12345678);
const d2 = makeDate(5, 5, 5);
const d3 = makeDate(1, 3);
//No overload expects 2 arguments, but overloads do exist that expect either 1 or 3 arguments.
오버로드 시그니처와 구현 시그니처
- 다소 혼란스러울 수 있는 부분이 다음처럼 작성하는 경우다.
function fn(x: string): void;
function fn() {
// ...
}
// 0개의 인자로 호출하기를 예상했음
fn();
// Expected 1 arguments, but got 0.
- 함수 본문을 작성하기 위해 사용된 시그니처는 외부에서 보이지 않는다. 즉, 해당의 경우 0개의 경우는 예상되지 못한다는 점이다.
- TS에서 함수 오버로드 시 차이점은, 오버로드 시그니쳐들의 종합이 구현 시그니처 부분에 담겨져 있어야 한다. 그래서 위에서 보였던
makeDate
의 구현 시그니쳐가 옵셔널과 전체 값을 포함하는 형태가 구성이 된 것이다. 따라서, 위의 실패 예시를 개선하려면 다음처럼 하면 된다.
function fn(): void;
function fn(x: string): void;
function fn(x?: string) {
// ...
}
fn(); // 문제없이 실행이 가능하다.
- 구현 시그니쳐는 오버로드 된 시그니처와 호환 되어야 하고, 다음 과 같은 실수가 생길 수 있다.
function fn(x: boolean): void;
// 인수 타입이 옳지 않습니다.
function fn(x: string): void;
// This overload signature is not compatible with its implementation signature.
function fn(x: boolean) {}
function fn(x: string): string;
// 반환 타입이 옳지 않습니다.
function fn(x: number): boolean;
// This overload signature is not compatible with its implementation signature.
function fn(x: string | number) {
return "oops";
}
좋은 오버로드 작성하기
function len(s: string): number;
function len(arr: any[]): number;
function len(x: any) {
return x.length;
}
- 돌아갈 수는 있는 오버로드의 형태일 것이다. 그러나 두 오버로드는 공통점으로 같은 인수 개수, 같은 반환 타입을 가지니 다음 처럼 바꿀 수도 있을 것이다.
function len(x: any[] | string) {
return x.length;
}
- 가능하면 오버로드 대신 유니온 타입으로 구현하는 것이 훨씬 가독성을 확보할 수 있을 것이다. 함수 오버로드가 필요한 경우는 인자가 여러 개인 경우라는 점을 인지하자.
함수 내에서 this 선언하기
const user = {
id: 123,
admin: false,
becomeAdmin: function () {
this.admin = true;
},
};
- 위의 코드에서 사용되는 this 키워드의 사용방법 정도를 숙지해두면 충분하다. 하지만 좀더 this 객체가 표현하는 것에 대해 많은 통제가 필요할 수 있다. TS는 함수 본문에서 this의 타입을 정의하는데 사용하도록 허락해준다.
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
const admins = db.filterUsers(function (this: User) {
return this.admin;
});
- 이 패턴은 콜백 스타일 API에서 흔히 사용되는 구조인데, 이런 효과를 얻기 위해선 화살표 함수가 아닌
function
키워드를 써야만 한다.
interface DB {
filterUsers(filter: (this: User) => boolean): User[];
}
const db = getDB();
const admins = db.filterUsers(() => this.admin);
//The containing arrow function captures the global value of 'this'.
//Element implicitly has an 'any' type because type 'typeof globalThis' has no index signature.
알아야 할 다른 타입
void
- 값을 반환하지 않는 함수의 반환을 의미한다.
- 그런데 JS에서 아무것도 반환하지 않는 함수는 암묵적으로 undefined 값을 반환한다. 하지만 TS에서 void와 undefined는 같은 것으로 간주 되지 않는다.
object
- 이 타입은 원시 값(
string
,number
,bigint
,boolean
,symbol
,null
,undefined
)를 제외한 모든 값을 지칭한다. - JS에서 함수 값은 객체이고, 프로퍼티가 있으며 프로토타입 체인에 Object.prototype 가 있으며, instanceof Object이면서, Object.keys를 호출할 수 있는 등의 특징을 가진다. 따라서 TS에서도 함수 타입은
object
로 간주한다.
unknown
- any와 유사하지만 어떤 것을 대입하는 것이 유효하지 않으므로 안전한 타입이다.
function f1(a: any) {
a.b(); // OK
}
function f2(a: unknown) {
a.b();
// 'a' is of type 'unknown'.
}
- 안전하지 못한
any
타입을 사용하지 않고도 아무 값이나 받는 함수를 표현할 수 있어서 유용하다. 또 반대로unknown
타입의 값을 반환하는 함수를 표현할 수도 있다.
function safeParse(s: string): unknown {
return JSON.parse(s);
}
// 'obj'를 사용할 때 조심해야 합니다!
const obj = safeParse(someRandomString);
never
- 어떤 함수는 결코 값을 반환하지 않는다.
- 해당 타입은 결코 관측, 즉 값으로 나올 수 없을 의미한다. 예외를 발생시키거나, 프로그램 실행의 종료를 표현할 수있다.
never
는 이전에 언급했듯, TS가 유니온에 아무것도 남아 있지 않다고 판단했을 때 또한 나타낸다.
function fail(msg: string): never {
throw new Error(msg);
}
function fn(x: string | number) {
if (typeof x === "string") {
// do something
} else if (typeof x === "number") {
// do something else
} else {
x; // 'never' 타입이 됨!
}
}
Function
- 전역 타입인
Function
은bind
,call
,apply
그리고 JS 함수 값에 있는 다른 프로퍼티를 설명하는데 사용된다. 또한 이 타입은 언제나 호출 될 수 있다는 의미를 가지고, 이러한 호출은any
를 반환한다.
function doSomething(f: Function) {
return f(1, 2, 3);
}
- 이러한 형태는 보시다시피 any 반환이 되므로 일반적으로 피하는 것이 맞다.
나머지 매개변수와 인수
나머지 매개변수(Rest Parameter)
- 정해지지 않은 숫자의 인수를 받아들이는 경우 사용되는 방식이다.
function multiply(n: number, ...m: number[]) {
return m.map((x) => n * x);
}
// 'a' gets value [10, 20, 30, 40]
const a = multiply(10, 1, 2, 3, 4);
- TS에서 이러한 매개변수에 대한 타입 표기는 당연히
any[]
로 되고, 타입 표현식은Array<T>
,T[]
또는 튜플 타입으로 표현하는게 낫다.
나머지 인수(Rest Argument)
- 전개 구문을 사용하여 배열에서 제공되는 인수의 개수를 제공할 수 있다.
const arr1 = [1, 2, 3];
const arr2 = [4, 5, 6];
arr1.push(...arr2);
- 그런데 여기서 TS는 배열이 불변하다고 간주하지 않아서, 다음과 같은 에러를 보여주기도 한다.
// 추론된 타입은 0개 이상의 숫자를 가지는 배열인 number[]
// 명시적으로 2개의 숫자를 가지는 배열로 간주되지 않습니다
const args = [8, 5];
const angle = Math.atan2(...args);
// (parameter) Math.atan2(y: number, x: number): number
//A spread argument must either have a tuple type or be passed to a rest parameter.
- 그래서 보통 이러한 형태를 해결하려면 리터럴로 확정을 지어야 하므로
const
콘텍스트가 가장 간단한 해결책이된다.
// 길이가 2인 튜플로 추론됨
const args = [8, 5] as const;
// OK
const angle = Math.atan2(...args);
매개변수 구조 분해(Parameter Destructing)
- JS의 그것처럼 제공되는 객체 내부의 값들을 지역변수로 편리하게 언팩해서 개별로 사용이 가능하다.
function sum({ a, b, c }) {
console.log(a + b + c);
}
sum({ a: 10, b: 3, c: 9 });
function sum({ a, b, c }: { a: number; b: number; c: number }) {
console.log(a + b + c);
}
// 이전 예제와 동일
type ABC = { a: number; b: number; c: number };
function sum({ a, b, c }: ABC) {
console.log(a + b + c);
}
함수의 할당 가능성
void 반환 타입
- void 반환 타입들은 문맥적으로 타이핑을 통해 함수가 아무것도 반환하지 않도록 강제하지 않는다. 아무 값이나 반환은 되지만 무시되도록 만들 수 있는 것이다.
type voidFunc = () => void;
const f1: voidFunc = () => {
return true;
};
const f2: voidFunc = () => true;
const f3: voidFunc = function () {
return true;
};
- 모든 함수는 결국 void 타입을 유지한다.