본문 바로가기

javascript/typescript

[typescript] structural typing


nominal typing

 타입의 이름을 기준으로 타입을 구분하는 방식. 두 유형의 이름이 동일하면 동일한 것으로 간주하며, 타입 T1이 T2의 서브타입으로 간주되기 위해서는 반드시 extends와 같은 구문을 이용하여 명시적으로 서브타입임을 선언해야 한다.

 위 특성에 의해 nominal typing을 채택하는 언어에서는 내부 속성 및 메서드를 동일하게 가지고 있더라도 클래스의 이름이 다르면 다른 것으로 간주되며, 인터페이스 / 클래스를 구체적으로 상속해야만 해당 타입의 서브타입으로 규정된다. C++, C#, Java 같은 언어들이 이 타이핑 방식을 채택하고 있다.

 nominal typing은 우연으로 인한 타입 호환을 허용하지 않으므로 타입 안정성이 높다는 장점이 있다. 일반적으로 정적 언어에서 많이 사용된다.

class Hello {
    public static void Main() {
        var sparrow = new Sparrow();
        var fly = new Fly();
        Sparrow test1 = fly;
        Fly test2 = sparrow;
        
        printFly(sparrow);
    }

    static void printFly(IFly flyable) {
        flyable.fly();
    }
}
class Bird {
    public String name;

    public void fly() {
        System.out.println("bird flies");
    }
}

interface IFly {
    public void fly();
}


class Sparrow extends Bird {
    public int size;
}

class Fly { // 파리
    public String name;
    public int size;

    public void fly() {
        System.out.println("fly flies");
    }
}

 위 코드는 Java 언어로 작성된 것으로, Sparrow와 Fly 클래스 및 IFly 사이의 nominal typing을 테스트하고 있다. 클래스 Sparrow와 Fly는 동일한 구조를 가지고 있는 별개의 클래스이다. IFly 인터페이스는 Sparrow 및 Fly 클래스의 공통적인 인터페이스를 표현하고 있으나, 명시적으로 상속 관계가 지정되지는 않았다.

에러가 발생한 라인
출력된 에러메시지

위 에러메시지는 2개의 문제를 지적하고 있다.

  1. Sparrow와 Fly 클래스는 서로 호환되지 않는다.
  2. Sparrow 클래스는 IFly 인터페이스에 적용되지 않는다.

발생한 에러 메시지들은 (1) 필드, 메서드가 동일하더라도 클래스의 이름이 다르면 호환되지 않는다는 특징과 (2) 인터페이스를 명시적으로 구현하지 않으면 적용할 수 없는 nominal tying의 특징을 잘 보여준다.


structural typing

 타입의 구조가 같다면 같은 것으로 간주하는 방식. 서로 동일한 공개 필드와 메서드를 가지고 있다면 두 유형은 동일한 것으로 간주한다. 또한 타입 T1이 T2와 동일한 필드 및 호환되는 메서드 ( +a )를 가지고 있다면 T2는 T1의 서브 타입으로 간주될 수 있다. 공식 문서에서는 이를 덕 타이핑이라고도 말한다.

 구조적 타이핑은 명목적 타이핑 방식에 비해 상대적으로 유연하다는 장점이 있다.

// 인터페이스 테스트 함수
function printFly(flyable: IFlyable) {
  flyable.fly();
}

interface IFlyable {
  fly: () => void
}
// Bird 관련 클래스
class Bird {
  name: string;

  fly() {
    console.log("새날다");
  }

  constructor(name: string) {
    this.name = name;
  }
}

class Sparrow extends Bird {
  size: number;

  override fly() {
    console.log("참새 날다")
  }
  constructor(name: string, size: number) {
    super(name);
    this.size = size;
  }
}
//Fly 관련 클래스
class Fly {
  name: string;
  size: number;

  fly() {
    console.log("파리 날다")
  }

  constructor(name: string, size: number) {
    this.name = name;
    this.size = size;
  }
}
// 단순 객체인 경우
const obj = {
  name: 'ufo',
  size: 10000,
  hello: 'value',
  fly() {
    console.log("ufo Fly");
  }
};
// 테스트 부분
const sparrow = new Sparrow("참새", 10);
const fly = new Fly("파리", 1);
const test1: Fly = sparrow;
const test2: Sparrow = fly;
const test3: Fly = obj;
const test4: Sparrow = obj;
printFly(sparrow);
printFly(fly);
printFly(obj);

 위 코드는 타입스크립트의 구조적 타이핑을 테스트한다. Sparrow, Fly 및 객체 obj는 모두 동일한 구조를 가지고 있으며, IFlyable은 이들에 공통된 fly() 메서드를 포함하지만 명시적으로 상속 관계가 지정되지는 않았다. 전체적인 구조는 이전 Java 기준으로 작성된 코드와 동일하다.

결과. 어떠한 에러도 발생하지 않음

 타입스크립트는 구조적 타이핑을 채택하고 있으므로 실제로 어떠한 에러도 발생하지 않는다.


structural typing 사용 예시

구조적 타이핑이 프레임워크에 사용되는 예시로는 nestjs의 custom provider이 있다.

https://docs.nestjs.com/fundamentals/custom-providers#custom-providers-1

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

nestjs은 provider을 주입할 때 use~ 형식의 프로퍼티를 통해 클래스 대신 객체나 값을 전달할 수 있는 기능을 제공한다.

const mockupMessagesService = {
  findOne: async (id: string) => `id = ${id}`,
  findAll: async () => ['1','2','3'],
  create: async () => {console.log("create")}
};
@Module({
  controllers: [AppController, MessagesController],
  providers: [
    AppService,
    {
      provide: MessagesService,
      useValue: mockupMessagesService
    },
    // MessagesService,
    {
      provide: 'MSG_REPO',
      useClass: MessageRepository
    }
  ]
})
export class AppModule {
  constructor() {
    console.log("App Module Created");
  }
}

위 코드에서 MesagesService은 토큰으로만 사용되며, useValue을 통해 가짜 개체인 mockupMessagesService으로 주입된다. 만약 타입스크립트(근본적으로는 자바스크립트)가 명목적 타이핑 방식을 채택했다면 둘은 같은 클래스가 아니므로 목업 객체가 대신 주입될 수 없었을 것이다.

 nestjs 공식 문서에서는 위 동작이 타입스크립트의 구조적 타이핑 덕분이라고 하기는 했지만, 개인적으로는 별 상관이 없다고 생각한다. provider들이 가지는 의존성은 보통 클래스 내에서 private으로 나타내는 경우가 많은데, 구조적 타이핑은 private 프로퍼티가 있는 경우 적용되지 않기 때문이다.

 예를 들어 앞에서 제시한 Sparrow 클래스와 Fly 클래스에 test라는 private 프로퍼티를 추가해 보자.

(좌) sparrow에만 private 프로퍼티 추가, (우) 두 클래스 모두에 private property추가
발생한 에러 메시지

 좌측은 Sparrow에만 private 프로퍼티를 추가한 상황이다. 이 경우 Fly에 비해 Sparrow에 추가적인 프로퍼티가 있는 경우이므로 Sparrow가 Fly의 서브 타입으로 간주된다. 따라서 fly객체나 obj 객체를 하위 타입으로 변환하려는 test2, test4의 행위는 잘못된 것이 맞다.

 우측의 경우 양 클래스에 private 프로퍼티를 추가한 상황으로, 타입스크립트에서는 ts2322 에러를 통해 형식에 별도의 private이 존재함을 알린다. 두 타입이 겹치기 위해서는 동일한 구조를 가져야 하는데, private / protected로 선언된 프로퍼티가 있는 경우 각 클래스마다 다른 것으로 판정하여 형식이 겹치지 않는다.

 이러한 특성으로 인해 nestjs에서 private 프로퍼티를 가진 클래스의 provider에 대해 "정확하게" 타입 유형을 지정하기 시작하면 에러가 발생할 수 있다.

(좌)ValueProvider을 적용했을 때 에러 발생. (우)MessagesServices를 그대로 복붙해도 안됨.

 nestjs는 value, class, factory 및 existing에 대한 provider 인터페이스를 _Provider 형식으로 제공한다. 이때 위와 같이 따로 해당 인터페이스를 사용하지 않는 경우 기본 값은 _Provider<any>이므로 typescript에 의한 정적 타입 검사 기능이 없다. 정적 기능이 필요하다고 생각해서 위와 같이 클래스를 그대로 제네릭 인자로 전달하면 private 프로퍼티로 인해 구조적 타이핑이 적용되지 않는다.

 좌측의 경우 useValue을 통해 mockup 객체를 MessageService로 주입한다. 실제 코드는 문제 없이 동작하지만, 타입스크립트는 객체인 mockupMessagesService가 private 속성을 구현하고 있지 않으므로 에러를 발생시킨다.

 우측의 경우 MessagesService를 그대로 복사한 클래스인 TestMessagesService을 주입하는 상황이다. 이 경우에도 앞에서 보인 것처럼 구조가 완벽히 동일하더라도 private 프로퍼티로 인해 구조적 타이핑이 적용되지 않는다.

 

structural typing 적용되지 않는 문제 해결하기

 위 상황은 Provider의 제네릭 인자로 private 변수를 가진 클래스를 바로 전달하지 않고, 해당 클래스에 대한 public 프로퍼티만 가진 인터페이스를 제공하면 해결된다. useValue는 단순한 mockup 수준으로 사용하는 경우가 많기 때문에 애초에 모든 메서드를 구현하지 않는 경우가 많다. 이 경우 Partial 유틸리티 타입을 이용하여 인터페이스로 표현할 수 있다.

유틸리티 타입을 통해 클래스에 대한 인터페이스로 변환하여 제네릭으로 넘긴 모습

이외로 발견한 편법이 있는데, Omit 유틸리티 타입을 이용하는 것이다. Omit은 전달된 인터페이스에서 특정 프로퍼티를 제거한 타입을 제공한다. 이때 Omit 내부에서 사용되는 keyof 연산자는 외부로 노출될 수 있는 public 프로퍼티만 키 속성으로 추출하기 때문에 private 속성이 없는 인터페이스를 제공한다. 

type ToInterface<T> = {[P in keyof T]: T[P]};

 keyof의 특성만을 이용하여 다음과 같은 유틸리티 타입을 작성할 수도 있다. ToInerface<T>는 T 타입의 공개 속성만을 추출하는 동작을 수행하며, MessagesService처럼 인터페이스가 필요하지만 만들기는 귀찮은 상황에서 잘 사용할 수 있을 것 같다.

ToInterface는 잘 동작하고 있다.

ToInterface가 포함된 소스코드는 [여기]