Google의 TypeScript Style Guide > Language features > 4.4 Classes

FeelsBotMan·2024년 12월 5일
0

GTS

목록 보기
5/8
post-thumbnail

4 Language features

4.4 Classes

4.4.1 Class declarations

클래스 선언은 세미콜론으로 끝나서는 안 된다:

class Baz {
  method(): number {
    return this.x;
  }
}

class Foo {

  method(): number {
    return this.x;
  }

}  // 공백 스타일은 선택 가능

클래스 표현식을 포함하는 명령문은 세미콜론으로 끝나야 한다:

export const Baz = class extends Bar {
  method(): number {
    return this.x;
  }
};

4.4.2 Class method declarations

클래스 메서드 선언에서는 세미콜론을 사용하지 않는다:
Before

class Foo {
  doThing() {
    console.log("A");
  }; // <-- unnecessary
}

After

class Foo {
  doThing() {
    console.log("A");
  }
}

메서드 선언은 주변 코드와 단일 빈 줄로 구분되어야 한다:
Before

class Foo {
  doThing() {
    console.log("A");
  }
  getOtherThing(): number {
    return 4;
  }
}

After

class Foo {
  doThing() {
    console.log("A");
  }

  getOtherThing(): number {
    return 4;
  }
}

toString 메서드는 디버깅과 로깅에서 자주 호출되므로 안정적이고 예외가 없는 방식으로 동작해야 한다:
부작용을 유발하거나 무한 루프, 예외 발생 가능성을 초래하는 코드는 피한다.
가능한 단순하게 구현하되, 객체 상태를 적절히 표현하는 문자열을 반환해야 한다.


4.4.3 Static methods

private 정적 메서드(static methods)는 지양하자:

private 정적 메서드
클래스 내부에서만 호출할 수 있다. 클래스 외부나 서브클래스에서는 접근할 수 없다. 즉, 해당 메서드는 클래스 자신이 정의된 곳에서만 사용 가능한 보조적인 기능을 제공한다.
일반 정적 메서드 (예: public, protected)
클래스 외부에서도 호출할 수 있다. public 정적 메서드는 어디서든 접근할 수 있고, protected 정적 메서드는 서브클래스에서만 접근할 수 있다.

모듈 로컬 함수란?
모듈 로컬 함수는 클래스나 객체의 메서드가 아니라, 파일 단위로 정의된 함수로, 해당 모듈 내에서만 사용할 수 있다.
클래스와 무관하게 독립적인 로직을 처리할 수 있으므로 다른 클래스나 모듈에서 쉽게 가져다 쓸 수 있다.

`private`` 정적 메서드를 피해야 하는 이유

  • 단일 책임 원칙: 클래스는 자신의 책임에 집중해야 한다. private 정적 메서드는 클래스의 주요 책임과 관련이 없는 보조적인 역할을 할 때가 많아, 클래스를 더 복잡하게 만든다.
  • 테스트 용이성: 모듈 로컬 함수는 독립적으로 테스트가 가능하지만, private 정적 메서드는 클래스의 내부에 종속되어 있어 단위 테스트가 어려울 수 있다.

동적 디스패치에 의존하지 말라:
정적 메서드는 해당 메서드를 직접 정의한 기본 클래스에서만 호출해야 한다.

동적 디스패치(Dynamic Dispatch)란?
메서드 호출 시점에 메서드가 어떤 클래스의 메서드인지 동적으로 결정되는 방식을 말한다.
이는 주로 다형성(Polymorphism)을 다룰 때 사용되지만, 정적 메서드 호출에서는 예상치 못한 동작을 초래할 수 있다.

서브클래스에서 정의되지 않은 메서드 호출

class Base {
  /** @nocollapse */ static foo() {}
}
class Sub extends Base {}

// BAD: 정적 메서드를 동적으로 호출
function callFoo(cls: typeof Base) {
  cls.foo();
}

// BAD: 서브클래스에서 정의되지 않은 정적 메서드 호출
Sub.foo(); // 이는 허용되지 않음

@nocollapse 데코레이터는 Closure Compiler에 의해 정적 메서드나 필드를 클래스 정의에서 분리하지 말라는 지시이다. 이로 인해 정적 메서드가 올바르게 상속되고, 동적 호출이 가능해진다.

정적 메서드에서 this 접근을 피해야 한다:
정적 필드가 상속되고, 서브클래스에서 재정의될 수 있다는 점은 명확하지 않으며, 실수로 잘못된 동작을 초래할 수 있다.
항상 클래스 이름을 명시적으로 사용하여 정적 필드나 메서드에 접근하자.
정적 상태를 최소화하고, 가능한 한 인스턴스 상태를 활용해 코드의 격리성을 높이자.

Before

class ShoeStore {
  static storage: Storage = ...;

  static isAvailable(s: Shoe) {
    return this.storage.has(s.id); // this.storage는 ShoeStore를 참조
  }
}

class EmptyShoeStore extends ShoeStore {
  static storage: Storage = EMPTY_STORE;
}

EmptyShoeStore.isAvailable(s); // EMPTY_STORE
  • 서브클래스가 정적 필드를 재정의하면 this.storageEmptyShoeStore.storage를 참조한다. 이 동작은 일반적으로 기대되지 않으며, 다른 언어에서는 유사한 패턴이 허용되지 않는 경우도 많다.

After

class ShoeStore {
  static storage: Storage = ...;

  static isAvailable(s: Shoe) {
    return ShoeStore.storage.has(s.id);  // Good: 명시적으로 클래스 이름을 사용
  }
}

class EmptyShoeStore extends ShoeStore {
  static storage: Storage = EMPTY_STORE;

  static isAvailable(s: Shoe) {
    return EmptyShoeStore.storage.has(s.id);  // Good: 명확하게 EmptyShoeStore를 참조
  }
}

4.4.4 Constructors

인수가 전달되지 않더라도 생성자 호출은 괄호를 사용해야 한다.

Before

const x = new Foo;

After

const x = new Foo();

괄호를 생략하면 미묘한 실수가 발생할 수 있다. 다음 두 줄은 동일하지가 않다.

new Foo().Bar();
new Foo.Bar();

ES2015에서는 기본 클래스 생성자를 제공하므로, 비어 있는 생성자나 부모 클래스에 위임하는 생성자를 제공할 필요가 없다. 생성자가 지정되지 않은 경우 기본 클래스 생성자가 제공된다.

Before

class UnnecessaryConstructor {
  constructor() {}
}

After

class DefaultConstructor {
}

Before

class UnnecessaryConstructor {
  constructor() {}
}

After

class DefaultConstructor {
}

그러나 매개변수 속성(parameter properties), 접근 제한자(modifier) 또는 매개변수 데코레이터(decorator)가 있는 생성자는 생성자의 본문이 비어 있더라도 생략해서는 안 된다.

class ParameterProperties {
  constructor(private myService) {}  // myService라는 속성을 클래스 내부에 생성하고, 생성자에서 초기화
}
class ParameterDecorators {
  constructor(@SideEffectDecorator myService) {}  //데코레이터를 사용해 매개변수에 부가적인 동작을 추가
}
class NoInstantiation {
  private constructor() {}  // 이 생성자는 private 접근 제어자를 사용해 클래스 외부에서의 인스턴스 생성을 막는다
}

생성자는 위와 아래의 주변 코드와 단일 빈 줄로 구분되어야 한다:

Before

class Foo {
  myField = 10;
  constructor(private readonly ctorParam) {}
  doThing() {
    console.log(ctorParam.getThing() + myField);
  }
}

After

class Foo {
  myField = 10;

  constructor(private readonly ctorParam) {}

  doThing() {
    console.log(ctorParam.getThing() + myField);
  }
}

4.4.5 Class members

#private 필드 사용금지:

  • 성능 및 크기 문제: #private 필드는 JavaScript의 최신 사양에 따른 문법으로, TypeScript가 ES2015 이하로 트랜스파일해야 할 경우 큰 문제가 된다. #private를 ES2015 이하로 다운레벨(downlevel)할 때, TypeScript는 별도의 WeakMap 기반 코드를 생성한다. 결과적으로 출력되는 코드의 크기가 커지고 성능도 저하될 수 있다.
  • 호환성: #private 필드는 ES2015 이상에서만 동작하며, ES2015 이전 환경에서는 지원되지 않는다. 만약 프로젝트가 ES5나 ES3와 같은 더 오래된 환경을 대상으로 한다면 사용 불가.
  • TypeScript의 타입 시스템으로 충분: TypeScript는 private 접근 제어자를 사용해 컴파일 타임에 필드 접근 제한을 강제할 수 있다. 런타임에서는 #private와 동일한 수준의 보호를 제공하지 않더라도, 컴파일 시 확인으로 대부분의 개발 요구사항을 충족할 수 있다.
    Before
class Clazz {
  #ident = 1;
}

After

class Clazz {
  private ident = 1;
}

생성자 외부에서 절대 다시 할당되지 않는 속성은 readonly 수정자를 권장한다.

  • readonly 키워드를 사용하면 필드가 생성자 외부에서 재할당되지 않음을 명시적으로 보장할 수 있다.

readonly얕은 불변성(Shallow Immutability)만 제공한다. (예를 들어, 객체의 속성 자체는 변경 가능하다.)

파라미터 프로퍼티(Parameter properties) 사용:
파라미터 프로퍼티는 간결하면서도 TypeScript의 강력한 타입 시스템을 활용해 코드 품질을 높이는 방법이다.
Before

class Foo {
  private readonly barService: BarService;

  constructor(barService: BarService) {
    this.barService = barService;
  }
}
  • 단순히 매개변수를 멤버로 할당하는 역할을 하며, 코드가 반복될 수 있다.

After

class Foo {
  constructor(private readonly barService: BarService) {}
}
  • 파라미터 프로퍼티(Parameter Properties)를 사용하면 생성자에서 매개변수에 접근 제한자(private, protected, public, 또는 readonly)를 추가해 멤버 변수를 자동으로 생성하고 초기화한다.

만약 파라미터 프로퍼티에 설명이 필요한 경우 JSDoc 주석을 사용해 가독성을 보완하자.

/**
 * A service for managing Foo entities.
 */
class Foo {
  /**
   * @param barService - The service to manage Bar-related operations.
   */
  constructor(private readonly barService: BarService) {}
}

생성자에서 초기화하기 보다는 필드 선언 시 초기화하자:
생성자에서 초기화 코드가 필요 없어지는 경우, 생성자를 생략할 수도 있다.

Before

class Foo {
  private readonly userList: string[];

  constructor() {
    this.userList = [];
  }
}

After

class Foo {
  private readonly userList: string[] = [];
}

인스턴스 생성 후 멤버를 추가하거나 제거하면 VM의 클래스 최적화가 어렵다. 선택적 필드(Optional Field)는 명시적으로 undefined로 초기화해 최적화에 방해가 되지 않도록 하자.

가시성 규칙
템플릿에 사용되는 속성은 protectedpublic을 사용하자. private는 외부 참조를 허용하지 않으므로 템플릿 사용 불가.

가시성 우회 금지:
컴파일러와 최적화 규칙의 안정성을 위해 권장되지 않음.

class Foo {
  private bar = 42;
}

const foo = new Foo();
console.log(foo['bar']); // BAD: 가시성 우회

Getter와 Setter란?
Getter: 클래스의 속성을 가져올 때 호출되는 메서드. 속성을 읽는 것처럼 사용할 수 있다.
Setter: 클래스의 속성을 설정할 때 호출되는 메서드.

Getter는 순수 함수여야 한다:
순수 함수(Pure Function)란 입력값에 따라 항상 동일한 결과를 반환하며, 부작용(Side Effects)이 없는 함수다.
Getter는 상태를 읽기만 해야 하며, 상태를 변경하면 안 된다.

Before

class Foo {
  nextId = 0;

  get next() {
    return this.nextId++; // BAD: Getter가 상태를 변경
  }
}

After

class Foo {
  private nextId = 0;

  getNext() {
    return this.nextId++; // GOOD: Getter 대신 메서드로 상태 변경
  }
}

Getter와 Setter로 구현된 속성은 가시성을 제한할 수 있다:
내부 구현을 숨기거나 간소화된 인터페이스를 제공할 때 유용하다.
숨겨진 속성에는 internal 또는 wrapped와 같은 접두사나 접미사로 붙일 수 있다.

class Foo {
  private wrappedBar = '';

  get bar() {
    return this.wrappedBar || 'default'; // 내부 변수를 가공해 반환
  }

  set bar(value: string) {
    this.wrappedBar = value.trim(); // 입력값을 가공 후 저장
  }
}

비효율적인 Getter/Setter는 피해야 한다:
단순 패스스루(pass-through) 형태의 Getter/Setter는 불필요하다.
이런 경우 속성을 public으로 선언하거나 readonly로 지정하는 것이 낫다.
Before

class Bar {
  private barInternal = '';

  // BAD: Getter/Setter에 추가 로직이 없음.
  get bar() {
    return this.barInternal;
  }

  set bar(value: string) {
    this.barInternal = value;
  }
}

After

class Bar {
  public bar: string = ''; // GOOD: 단순한 접근은 public으로 처리
}

Object.defineProperty로 Getter/Setter 정의 금지:
Object.defineProperty로 Getter/Setter를 정의하면 속성 이름 변경 시 문제가 발생할 수 있다.
TypeScript의 문법을 활용해 클래스 멤버에 Getter/Setter를 정의해야 한다.

계산된 속성(Computed properties)
계산된 속성은 속성 이름을 동적으로 정의하는 데 사용된다.
예를 들어, 객체 리터럴에서 대괄호 []를 사용해 속성 이름을 계산할 수 있다.

const key = 'dynamicKey';
const obj = {
  [key]: 'value', // 계산된 속성
};
console.log(obj.dynamicKey); // 'value'

클래스에서는 계산된 속성 이름으로 문자열이나 숫자 대신 Symbol만 사용할 것을 권장한다:

  • 문자열 기반 계산된 속성은 키 타입의 혼합을 유발하여 코드의 일관성을 해칠 수 있다.
  • Symbol은 충돌 가능성이 낮아 고유한 속성 이름으로 사용하기 적합하다.
const mySymbol = Symbol('myProperty');

class Example {
  [mySymbol] = 'This is a Symbol-based property';

  getSymbolProperty() {
    return this[mySymbol];
  }
}

const instance = new Example();
console.log(instance.getSymbolProperty());

Dict-style 속성 금지:
문자열 기반 계산된 속성은 객체의 키 타입을 혼합시켜 코드 일관성을 해치고 유지보수를 어렵게 만든다.

class BadExample {
  ["dynamicKey"] = 'value';
}

클래스가 논리적으로 반복(iterable) 가능하다면 반드시 [Symbol.iterator]를 정의해야 한다:
[Symbol.iterator] 메서드는 객체를 이터러블(iterable) 만들어 for...of 루프와 같은 반복문에서 사용할 수 있게 한다.

Symbol의 사용은 최소화하자. 일부 내장 Symbol(예: Symbol.isConcatSpreadable)은 TypeScript 컴파일러에서 폴리필되지 않으므로 사용에 주의하자.


4.4.6 Visibility

가시성을 가능한 한 제한적으로 설정하여, 외부에서 접근할 수 있는 범위를 최소화하자.
코드의 결합도를 낮추고 불필요한 의존성을 방지할 수 있다.

TypeScript는 다음 세 가지 접근 제어자를 제공한다:

  • public (기본값): 어디서든 접근 가능.
  • protected: 클래스 및 서브클래스에서만 접근 가능.
    -private: 같은 클래스 내부에서만 접근 가능.

TypeScript에서는 public은 기본값이므로, 명시적으로 선언할 필요가 없다:
readonly 속성은 기본적으로 public이므로, 별도로 public을 추가할 필요가 없다:
Before

class Foo {
  public bar = new Bar(); // BAD: public 선언 불필요

  constructor(public readonly baz: Baz) {} // BAD: readonly 자체가 기본적으로 public
}

After

class Foo {
  bar = new Bar(); // GOOD: public 생략 가능

  constructor(public baz: Baz) {} // GOOD: public은 parameter property에서만 허용
}

private 메서드를 클래스 외부에서 사용할 필요가 없다면, 클래스 외부의 비-내보내기(non-exported) 함수로 변환할 것을 권장한다:
클래스 정의가 단순해지고 외부 접근 불가를 더 명확히 보장하여 코드를 더욱 깔끔하고 안정적으로 유지할 수 있다.

Export visibility 참고


4.4.7 Disallowed class patterns

프로토타입 직접 조작 금지:

  • 가독성 저하: 프로토타입을 직접 조작하면 코드가 복잡하고 이해하기 어려워짐. class 키워드 사용은 클래스 정의를 더 명확하고 읽기 쉽게 만들어줌.
  • 예측 가능성 손상: 객체의 동작이 명시적이지 않게 변할 수 있어 디버깅이 어려워짐. 예를 들어, 프로토타입 체인을 수정하면 코드 실행 중 예상치 못한 동작을 초래할 수 있음.
  • 호환성 문제: 기본 제공 객체(builtin objects)의 프로토타입을 수정하면 다른 코드와 충돌하거나 브라우저 간 호환성이 깨질 가능성이 있음.
  • 표준 위반: ECMAScript 표준은 기본 제공 객체(builtin objects)의 프로토타입 수정 금지를 권장.

Before

// BAD: 기존 객체의 프로토타입을 수정
Array.prototype.customMethod = function () {
  return this.length;
};
  • 모든 Array 객체에 customMethod가 추가됨.
  • 다른 라이브러리나 코드에서 동일한 이름의 메서드를 정의하면 충돌 위험.
  • 예상치 못한 동작과 디버깅 문제 발생 가능.

After

class CustomArray extends Array {
  customMethod(): number {
    return this.length;
  }
}

const myArray = new CustomArray();
console.log(myArray.customMethod());

프레임워크 코드는 예외적으로 프로토타입 조작을 허용:
Polymer나 Angular와 같은 프레임워크는 성능 최적화, 동작 확장을 위해 프로토타입을 조작해야 할 때가 있음.
이러한 경우는 성능과 구조적 요구를 기반으로 결정됨.


참고자료

Google TypeScript Style Guide

GitHub - google/gts: ☂️ TypeScript style guide, formatter, and linter.

Typescript Google Code Style Part 1
Typescript Google Code Style Part 2
Typescript Google Code Style Part 3

ts.dev - TypeScript style guide

profile
안드로이드 페페

0개의 댓글