TypeScript, 함수 내에서 this 선언하기

아래와 같은 코드가 있습니다.

interface User {
  id: number;
  name: string;
  admin: boolean;
}

interface DB {
  filterUsers(filter: (/*this: User*/) => boolean): User[];
}

class UserDB implements DB {
  private users: User[] = [
    { id: 1, name: "Alice", admin: true },
    { id: 2, name: "Bob", admin: false },
    { id: 3, name: "Charlie", admin: true },
    { id: 4, name: "David", admin: false },
  ];

  filterUsers(filter: (/*this: User*/) => boolean): User[] {
    const result: User[] = [];

    for (const user of this.users) {
      // filter 함수의 this를 현재 user로 지정하여 호출
      if (filter.call(user)) {
        result.push(user);
      }
    }

    return result;
  }
}

function getDB(): DB {
  return new UserDB();
}

// ------------------------
// 사용 예제
// ------------------------

const db = getDB();

const admins = db.filterUsers(function (this: User) {
  return this.admin;
});

console.log(admins);

중요한 것은 43번째 줄의 인자로 받은 함수의 인자가 this: User라는 것입니다. 인자로 받은 이 함수는 filter라는 이름으로 24번째 줄에서 filter.call(user)로 호출되고 있습니다. 43번째의 코드는 Javascript에서는 허락하지 않는 문법입니다. Type를 항상 확인해야만 하는 TypeScript에서는 이 this: User를 통해 인자로 받는 함수 내부에서의 this의 타입도 확인을 해야하므로 개발자가 직접 this의 타입을 User라고 명시 해준 것입니다. this: User가 없을 경우 thisany 타입으로 추론될 것이기 때문입니다. 이렇게 된 이유는 filter.call(user)와 같은 형태로 호출되고 있기 때문인데, 이러한 호출 방식은 오래된 방식입니다. 하지만 지금도 매우 많이 사용되고 있는 방식이기도 합니다. 그럼 지금 방식(정확히는 제가 주장하는 방식)으로 바꾼다면 다음과 같습니다.

interface User {
  id: number;
  name: string;
  admin: boolean;
}

interface DB {
  filterUsers(filter: (user: User) => boolean): User[];
}

class UserDB implements DB {
  private users: User[] = [
    { id: 1, name: "Alice", admin: true },
    { id: 2, name: "Bob", admin: false },
    { id: 3, name: "Charlie", admin: true },
    { id: 4, name: "David", admin: false },
  ];

  // filterUsers(filter: (this: User) => boolean): User[] {
  filterUsers(filter: (user: User) => boolean): User[] {
    const result: User[] = [];

    for (const user of this.users) {
      // filter 함수의 인자에 직접 현재 user를 지정하여 호출
      if (filter(user)) {
        result.push(user);
      }
    }

    return result;
  }
}

function getDB(): DB {
  return new UserDB();
}

// ------------------------
// 사용 예제
// ------------------------

const db = getDB();

const admins = db.filterUsers((user: User) => {
  return user.admin;
});

console.log(admins);

명확합니다. 저는 이 방식을 선호하지만.. 현재 제공되는 매우 유명한 라이브러리들이 이 방식으로 제공되지 않으므로 전자의 방식도 알아두고 있어야 합니다.

TypeScript, 생성(구성) 시그니처(Construct Signature)

생성 시그니처는 클래스의 생성자에 대한 함수 타입을 정의합니다. 예제 코드를 보면 …

임의의 클래스에 대한 생성자의 생성 시그니처를 정의합니다.

interface PersonConstructor {
  new (name: string): Person;
}

상기 생성 시그니처는 Person 클래스의 객체를 생성하는 생성자 함수의 타입이며 생성자는 name이라는 문자열 타입을 받습니다. 이제 이 생성 시그니처를 통해 생성될 객체의 클래스를 정의합니다.

class Person {
    constructor(public name: string) {}
}

이제 앞서 정의한 생성 시그니처를 통해 Person 클래스의 객체를 생성하는 함수를 정의합니다.

function createPerson(
    ctor: PersonConstructor,
    name: string
): Person {
    return new ctor(name);
}

사용 코드는 다음과 같습니다.

const person = createPerson(Person, "Alice");

console.log(person.name); // Alice

생성 시그니처는 제니릭으로 많이 작성되는데.. 예는 다음과 같습니다.

class Person {
    constructor(public name: string) {}
}

class Student {
    constructor(public name: string) {}
}

function createInstance<T>(
    ctor: new (name: string) => T,
    name: string
): T {
    return new ctor(name);
}

const p = createInstance(Person, "Alice");
const s = createInstance(Student, "Bob");

console.log(p); // Person
console.log(s); // Student

TypeScript, 객체의 프로퍼티들을 상수화(정확히는 리터럴 타입화) 시키는 as const

const req = { url: "https://example.com", method: "GET" };
req.method = "XXX";

위의 코드는 유효합니다. req는 비록 상수 객체로 선언되어있지만 그 구성 프로퍼티들은 변경이 가능한 string 타입입니다. 하지만 이렇게 될 경우 문제가 있을 수 있습니다. 예를들어서 다음과 같은 함수가 있다고 해보겠습니다.

function requestURL(url: string, method: "GET" | "POST") {
  // ..
}

requestURL(req.url, req.method);

requestURL 함수의 두번째 인자는 유니언 타입입니다. 그러나 req.method는 string 타입이구요. 즉, req.method는 언제든 GET이나 POST가 아닌 다른 문자열 값이 될 수 있습니다.

해결 방법은 as const를 사용하는 것입니다.

const req = { url: "https://example.com", method: "GET" } as const;
// req.method = "XXX"; -> ERROR

as const에 의해 req의 프로퍼티들은 더 이상 string이 아닌 리터릴 타입이 됩니다. 앞서 봤던 requestURL 함수 호출도 더 이상 문제가 되지 않습니다.

TypeScript, Capitalize에 대하여

Capitalize의 사용 예시 코드로 다음이 있습니다.

type Getters<Type> = {
  [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property]
};

interface Person {
  name: string;
  age: number;
  location: string;
}

type LazyPerson = Getters<Person>;

위의 코드는 LazyPerson에 대한 타입을 다음처럼 자동으로 구성해 줍니다.

type LazyPerson = {
  getName: () => string;
  getAge: () => number;
  getLocation: () => string;
}

위의 코드에서 설명할 내용이 산더미 같지만 일단 집중하고자 하는 것은 Capitalize 입니다. Capitalize는 TypeScript로 작성되지 않은 Native 코드입니다. Capitalize가 Native로 작성되었지만 TypeScript로도 작성될 수 있는데요. TypeScript로 작성된 Capitalize은 다음과 같습니다.

type UpperMap = {
  a: "A";
  b: "B";
  c: "C";
  d: "D";
  e: "E";
  f: "F";
  g: "G";
  h: "H";
  i: "I";
  j: "J";
  k: "K";
  l: "L";
  m: "M";
  n: "N";
  o: "O";
  p: "P";
  q: "Q";
  r: "R";
  s: "S";
  t: "T";
  u: "U";
  v: "V";
  w: "W";
  x: "X";
  y: "Y";
  z: "Z";
};

type MyCapitalize<S extends string> =
  S extends `${infer First}${infer Rest}`
  ? `${First extends keyof UpperMap ? UpperMap[First] : First}${Rest}`
  : S;

type A = MyCapitalize<"hello">;      // "Hello"
type B = MyCapitalize<"typescript">; // "Typescript"
type C = MyCapitalize<"안녕?">;     // "안녕?"
type D = MyCapitalize<"Hello">;      // "Hello"

TypeScript, 조건부 타입에 대한 분산 처리

아래의 코드는 주어진 타입에 대한 배열 타입을 만들어주는 코드입니다. 주어진 타입이 유니언일 경우 각 타입이 하나씩 분산 처리되어 유니온화 됩니다.

type ToArray<Type> = Type extends any ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = string[] | number[]

만약 주어진 타입을 분산 처리하지 않고 한번에 처리하기를 원한다는 다음처럼 작성하면 됩니다.

type ToArray<Type> = [Type] extends [any] ? Type[] : never;
type StrArrOrNumArr = ToArray<string | number>;
// type StrArrOrNumArr = (string | number)[]