인덱스 서명(Index Signature)

JavaScript에서 (따라서 TypeScript)에서 Object에 들어있는 다른 어떤 JavaScript 객체 참조도 문자열 로 액세스할 수 있습니다.

간단한 예제:

let foo: any = {};
foo['Hello'] = 'World';
console.log(foo['Hello']); // World

우리는 "World"라는 문자열을 "Hello"라는 키로 저장했습니다. 다른 어떤 JavaScript 객체 도 저장할 수 있다고 얘기했었죠, 그러니 이걸 확인할 수 있도록 클래스 인스턴스를 저장해 봅시다:

class Foo {
  constructor(public message: string){};
  log(){
    console.log(this.message)
  }
}

let foo: any = {};
foo['Hello'] = new Foo('World');
foo['Hello'].log(); // World

그리고 문자열 로 액세스 가능하다고 말한 것도 기억하세요. 인덱스 서명에 다른 타입의 객체을 넘기면 JavaScript 런타임이 실제로 그 객체에 대해 .toString을 호출합니다. 이것이 아래에 나와 있습니다:

let obj = {
  toString(){
    console.log('toString called')
    return 'Hello'
  }
}

let foo: any = {};
foo[obj] = 'World'; // toString called
console.log(foo[obj]); // toString called, World
console.log(foo['Hello']); // World

인덱스 위치에 obj가 사용될 때마다 toString이 호출된다는 점을 참고하세요.

배열은 약간 다릅니다. JavaScript VM은 number 인덱싱에 대해 최적화를 시도합니다 (그것이 실제 배열인지, 저장된 객체들의 구조가 일치하는 등에 따라). 그러므로 number도 (string과 다른 ) 별도의 유효한 객체 액세스 수단으로 생각해야 합니다. 여기 간단한 배열 예제가 있습니다:

let foo = ['World'];
console.log(foo[0]); // World

여기까지가 JavaScript입니다. 이제 TypeScript가 이 개념들을 어떻게 매끄럽게 처리하는지 살펴봅시다.

TypeScript 인덱스 서명

먼저 JavaScript가 객체 인덱스 서명에 사용된 객체에 대해 암묵적으로 toString를 호출하기 때문에 TypeScript는 초심자가 실수로 자기 발에 총을 쏘려 할 때 오류를 발생시킵니다 (필자는 Stackoverflow.com에서 사용자들이 자기 발을 쏘는 모습을 매일 봅니다):

let obj = {
  toString(){
    return 'Hello'
  }
}

let foo: any = {};

// 오류: 인덱스 서명은 string, number 여야 함...
foo[obj] = 'World';

// FIX: TypeScript는 명시적으로 호출하게 강제함
foo[obj.toString()] = 'World';

사용자가 명시적으로 처리하게 하는 이유는 대개 객체의 기본 toString 구현이 엉망이기 때문입니다. 예를 들어, v8은 항상 [object Object]를 반환합니다:

let obj = {message:'Hello'}
let foo: any = {};

// 오류: 인덱스 서명은 string, number 여야 함...
foo[obj] = 'World';

// 실제 저장 위치가 이렇게 됨!
console.log(foo["[object Object]"]); // World

당연히 number가 지원됩니다, 왜냐하면

  1. Array / Tuple을 잘 지원하기 위해 필요합니다.

  2. obj의 기본 toString 구현으로 숫자를 사용하는 건 쓸만 합니다 ([object Object] 보다는).

2번이 아래에 나와 있습니다:

console.log((1).toString()); // 1
console.log((2).toString()); // 2

그러므로 교훈 1:

TypeScript 인덱스 서명은 반드시 string 또는 number 여야 함

간단 참고: symbols도 유효하고 TypeScript에서 지원됩니다. 하지만 거기까진 가지 말기로 하죠. 조금씩 알아가기로.

인덱스 서명 선언하기

지금까지 우리는 any를 사용하여 TypeScript에게 우리 마음대로 하고 싶다고 말해 왔습니다. 우리는 인덱스 서명을 명시적으로 지정할 수 있습니다. 예를 들어, 문자열을 사용하여 객체에 저장된 것들은 {message: string} 구조를 준수하길 바랄 수 있습니다. 이것은 { [index:string] : {message: string} } 선언으로 할 수 있습니다. 아래에 나와 있습니다:

let foo:{ [index:string] : {message: string} } = {};

/**
 * 정의된 구조에 맞는 것들만 저장
 */
/** Ok */
foo['a'] = { message: 'some message' };
/** 오류: 타입이 string인 `message`가 있어야 함. `message` 부분에 오타 존재  */
foo['a'] = { messages: 'some message' };

/**
 * 내용을 읽을 때도 타입 검사가 이루어짐
 */
/** Ok */
foo['a'].message;
/** 오류: messages는 존재하지 않음. `message` 부분에 오타 존재 */
foo['a'].messages;

팁: 인덱스 서명의 이름, 즉 { [index:string] : {message: string} }에서 index는 TypeScript에서 아무 의미가 없으며 가독성을 위해 넣는 내용입니다, 예를 들어, 사용자 이름이라면 코드를 보게 될 다음 개발자의 이해를 돕기 위해 { [username:string] : {message: string} }라고 적을 수도 있습니다 (그 다음 개발자는 바로 당신이 될 수도).

당연히 number 인덱스도 지원됩니다. 예를 들어 { [count: number] : SomeOtherTypeYouWantToStoreEgRebate }

모든 구성원은 string 인덱스 서명을 준수해야

string 인덱스 서명을 만들면 모든 명시적인 구성원은 인덱스 서명을 준수해야 합니다. 아래에 나와 있습니다:

/** 오케이 */
interface Foo {
  x: number;
  y: number;
}
/** 오류 */
interface Bar {
  x: number;
  y: string; // 오류: `y` 속성은 number 타입이어야 함
}

이를 통해 안전성이 제공되고 어떤 문자열로 액세스해도 같은 결과가 나오게 됩니다:

interface Foo {
  x: number;
}
let foo: Foo = {x:1,y:2};

// 직접
foo['x']; // number

// 간접
let x = 'x'
foo[x]; // number

제한된 문자열 리터럴 집합 사용

타입 매핑(Mapped Type) 을 사용하여 인덱스 서명에서 인덱스 문자열이 리터럴 문자열 유니온의 구성원이 되게 할 수 있습니다. 예를 들면:

type Index = 'a' | 'b' | 'c'
type FromIndex = { [k in Index]?: number }

const good: FromIndex = {b:1, c:2}

// 오류:
// 타입 '{ b: number; c: number; d: number; }'은 타입 'FromIndex'에 할당 불가능.
// 객체 리터럴은 알려진 속성만 지정할 수 있고 'd'는 'FromIndex' 타입에 존재하지 않음.
const bad: FromIndex = {b:1, c:2, d:3};

종종 keyof typeof와 함께 사용하여 타입의 구성 단어를 추출할 수도 있습니다. 이 내용은 다음 페이지에서 설명합니다.

역주: 리터럴(Literal) 단원 참고

구성 단어의 명세는 제네릭한 방법으로 도출할 수도 있습니다:

type FromSomeIndex<K extends string> = { [key in K]: number }

인덱서로 stringnumber 둘 다 사용

일반적인 사용법은 아니지만 TypeScript 컴파일러는 이것도 지원합니다.

그렇지만 string 인덱서가 number 인덱서보다 더 구체적이어야 한다는 제약이 있습니다. 이것은 의도적인 것으로, 아래와 같이 타입을 정의할 수 있게 하기 위함입니다:

interface ArrStr {
  [key: string]: string | number; // 모든 구성원을 수용해야 함

  [index: number]: string; // string 인덱서의 일부만 수용 가능

  // 그냥 예제 멤버
  length: number;
}

디자인 패턴: 중첩 인덱스 서명

인덱스 서명을 추가할 때 API 고려 사항

매우 빈번하게 JS 커뮤니티에서 문자열 인덱서를 남용하는 API 들을 볼 수 있습니다. 예를 들어, JS 라이브러리에서 CSS를 다루는 흔한 패턴:

interface NestedCSS {
  color?: string;
  [selector: string]: string | NestedCSS | undefined;
}

const example: NestedCSS = {
  color: 'red',
  '.subclass': {
    color: 'blue'
  }
}

이런 식으로 문자열 인덱서와 유효한 값을 섞어서 쓰지 마세요. 이러면 오타가 발생해도 검출이 안됩니다:

const failsSilently: NestedCSS = {
  colour: 'red', // `colour`는 유효한 문자열 인덱서이므로 오류 아님
}

대신 중첩된 내용을 nest (아니면 children 아니면 subnodes 등) 같은 별도 이름 속성으로 분리하는 것이 좋습니다:

interface NestedCSS {
  color?: string;
  nest?: {
    [selector: string]: NestedCSS;
  }
}

const example: NestedCSS = {
  color: 'red',
  nest: {
    '.subclass': {
      color: 'blue'
    }
  }
}

const failsSilently: NestedCSS = {
  colour: 'red', // TS 오류: 정의되지 않은 속성 `colour`
}

인덱스 서명에서 특정 속성 제외시키기

때로는 여러 속성을 한 인덱스 서명으로 합쳐야 할 수 있습니다. 추천하는 방법은 아니고, 대신 앞서 설명한 중첩 인덱스 서명 패턴을 사용하는 것이 맞습니다.

그렇지만 기존 JavaScript 를 모델링하는 경우라면 교차 타입으로 해결할 수 있습니다. 다음은 교차 타입을 사용하지 않은 경우에 발생하는 오류를 보여줍니다:

type FieldState = {
  value: string
}

type FormState = {
  isValid: boolean  // 오류: 인덱스 서명을 준수하지 않음
}

이것은 교차 타입으로 문제를 해결하는 방법입니다:

type FieldState = {
  value: string
}

type FormState =
  { isValid: boolean }
  & { [fieldName: string]: FieldState }

이렇게 해서 기존 JavaScript 모델을 선언할 수는 있지만 이런 객체를 TypeScript로 생성할 수는 없다는 점을 유념해주세요:

type FieldState = {
  value: string
}

type FormState =
  { isValid: boolean }
  & { [fieldName: string]: FieldState }


// 다른데서 넘어오는 JavaScript 객체 용으로 사용
declare const foo:FormState; 

const isValidBool = foo.isValid;
const somethingFieldState = foo['something'];

// 이것으로 TypeScript 객체를 생성하는 것은 안됨
const bar: FormState = { // 오류 `isValid`는 `FieldState`에 할당할 수 없음
  isValid: false
}

Last updated