TypeScript, 초과 속성 검사(Excess Property Checks) / 일반적인 타입 호환성(구조적 타이핑, structural typing) 검사 / 약한 타입 감지(Weak Type Detection)

다음과 같은 코드가 있다고 하겠습니다.

interface SquareConfig {
  color?: string;
  width?: number;
}
 
function createSquare(config: SquareConfig): { color: string; area: number } {
  return {
    color: config.color || "red",
    area: config.width ? config.width * config.width : 20,
  };
}

위의 코드를 사용하는 다음 코드는 에러입니다.

let mySquare = createSquare({ colour: "red", width: 100 });

인정할만 합니다. createSquare의 인자는 SquareConfig 타입으로, 이 타입은 color 속성을 갖고 있지 colour 속성이 아니기 때문입니다. 이를 초과 속성 검사(Excess Property Checks)라고 합니다. 그런데 다음 코드는 …

let squareOptions = { colour: "red", width: 100 };
let mySquare = createSquare(squareOptions);

에러가 아닙니다. 앞서 에러가 있던 코드는 createSquare의 인자에 바로 객체 리터널를 직접 전달하고 있지만 두번째 에러가 없는 코드에서는 일단 객체 리터럴을 먼저 변수에 할당하고 그 변수를 함수에 전달하고 있습니다. 이렇게 하면 함수의 인자 타입과 변수 사이의 구조를 비교해서 그 구조이 맞다면 통과, 구조가 맞지 않다면 에러로 처리됩니다. SquareConfig는 모든 속성에 대해 옵셔널이므로 구조가 서로 호환된다고 판단되어 에러로 처리되지 않습니다. 이를 일반적인 타입 호환성(구조적 타이핑, structural typing) 검사라고 합니다. 그런데 다음 코드는 에러입니다.

let squareOptions = { colour: "red" };
let mySquare = createSquare(squareOptions);

SquareConfig는 모든 속성에 대해 옵셔널이므로 문제가 없어야 합니다. 하지만 SquareConfig 타입은 매우 특수한 경우로 처리됩니다. SquareConfig는 모든 속성이 옵셔널입니다. 이처럼 모든 속성이 옵셔널일 경우에는 반드시 속성 중 하나라도 같은 속성이 존재해야 합니다. 이런 검사를 약한 타입 감지(Weak Type Detection)라고 합니다.

TypeScript, 배열을 튜플로 …

다음 코드에서 객체는 배열입니다.

const args = [8, 5];

그래서 다음 코드는 에러입니다.

const angle = Math.atan2(...args);

에러의 내용은 “확산 인수는 튜플 유형을 가지거나 나머지 매개 변수로 전달되어야 합니다.”. 그럼 args를 배열이 아닌 튜플로 만들어줘야 합니다. 방법은 아래와 같습니다.

const args = [8, 5] as const;

as const는 그 구성 맴버까지 readonly로 만들어주는 것입니다. 튜플은 그 구성 항목의 개수가 고정이여야하므로 as const를 붙여 튜플로 만든다라는 표현은 합리적입니다.

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 함수 호출도 더 이상 문제가 되지 않습니다.