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

  • 전역 타입인 Functionbind, 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 타입을 유지한다.