typescript

타입스크립트에서 타입 호환성은 어떻게 체크되는가?

하리하링웹 2024. 3. 13. 22:56

 

아래의 코드를 봐보자

 

class Programmer {
  name:string;
  constructor(name:string) {
    
  }
}

class Manager {
  name:string;
  constructor(name:string) {
    
  }
}

let employee:Programmer = new Manager('Jongsik')

 

Programmer와 Manager는 같은 구조의 클래스이다. 타입스크립트에서는 이러한 상황에 Programmer 타입과 Manager 타입은 같은 타입이라고 판단하여 에러를 발생시키지 않는다.

 

아래 이미지와 같은 예시에서도 마찬가지이다 분명 Manager타입에 Programmer 타입을 넣었는데 에러가 발생하지 않는것을 확인할 수 있다.

 

이로써 알 수 있는것이 하나 있다. 타입스크립트는 타입의 형태를 보는것이지 이름을 보지 않는다.

 

타입스크립트에서는 Programmer에 멤버와 Manager에 있는 멤버가 일치하는 것으로 판단하여 에러를 발생시키지 않는것이다.

 

정확하게 말하면 Manager에 있는 멤버가 Programmer에 있는 멤버를 모두 포함하고 있어 에러가 발생하지 않는다고 말할 수 있다.

 

Manager 코드에 아래와 같이 age를 추가하면 어떨까?

class Manager {
  name:string;
  age:number
  constructor(name:string) {
  }
}

이 역시 에러를 발생시키지 않는다.

 

 

이는 employee를 Programmer 타입으로 선언했기 때문에 타입 스크립트 컴파일러에서 Programmer에 선언되어 있지 않는 멤버에 대해 접근을 허가하지 않을것이란것을 알고있으며 Manager의 안에 어떠한 멤버가 추가적으로 존재하더라도 Manager가 Programmer의 멤버를 포함하고 있다면 에러가 발생하지 않는다.

 

실제로도 아래 이미지처럼 employee 변수에서는 age를 사용할 수 없는것을 확인할 수 없다.

 

 

이러한 개념은 컴퓨팅 언어에서 흔히 서브 타이핑이라고 부르는 개념이다.

 

이 개념에서 Programmer와 같이 더 일반적인 타입을 슈퍼타입이라고 부르며 Manager와 같이 확장이 된 더 특수한 타입을 서브타입이라고 부른다.

 

이를 객체지향의 관점에서 설명하면 아래와 같다.

  • 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것이다.
  • 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것이다.

타입스크립트에서는 위 예시에서 보이는것처럼 구조적 서브타이핑을 채택하여 사용하고있으며 공식 문서에도 정확하게 이러한 명칭으로 명시되어있다.

 

구조적 서브타이핑은 상속 관계같은것이 따로 명시되어 있지 않더라도 객체의 프로퍼티를 기반으로 사용자의 사용에 문제가 없다면 타입의 호환을 허용하는 방식이다.

 

이러한 방식은 타입스크립트가 객체의 프로퍼티를 체크하는 과정을 수행해줌으로써 개발자가 직접 상속같은것을 명시하는것을 하지 않아도 되도록 만들어준다.

이러한 방식의 타이핑을 덕 타이핑이라고도 부른다

만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다..

  • 이와 반대로 C#, Java는 명시적으로 상속 관계를 정의해주어야하는 명목적 서브타이핑을 사용하고 있다.

 

 

Freshness란

 

하지만 예상과는 조금 다르게 동작하는 경우가 있다. 먼저 아래의 코드를 봐보자

type IProgrammer = {
  name:string
}

function test(a:IProgrammer) {}

test({
  name:'hello',
  age:123
})

위 코드는 위의 설명대로라면 정상적으로 동작할 것 같지만 에러가 발생한다.

 

왜 여기서는 에러가 발생하는것일까? 그건 타입스크립트 컴파일러에서 이러한 경우에 대해 예외처리를 하고있기 때문이다.

 

아래는 타입스크립트 컴파일러의 예외처리 코드이다.

const isPerformingExcessPropertyChecks = !(intersectionState & IntersectionState.Target) && (isObjectLiteralType(source) && getObjectFlags(source) & ObjectFlags.FreshLiteral);
                if (isPerformingExcessPropertyChecks) {
                    if (hasExcessProperties(source as FreshObjectLiteralType, target, reportErrors)) {
                        if (reportErrors) {
                            reportRelationError(headMessage, source, originalTarget.aliasSymbol ? originalTarget : target);
                        }
                        return Ternary.False;
                    }
                }

여기서 중요한것은 ObjectFlags.FreshLiteral 부분이다.

 

타입스크립트는 freshness라는 개념을 제공한다. 이는 구조적 서브타이핑에서 객체 리터럴이 실제로는 사용하지 않는 데이터를 받아들일 것처럼 오해하게 만드는 문제를 해결하기 위해 타입스크립트에서 제안한 컨셉이다.

 

모든 객체 리터럴은 처음에 freshness한 상태로 만들어지며 타입 추론에 의해 타입이 확정되거나 as같은 문법을 써서 type을 assertion하는 경우 freshness가 사라지게 된다. 위 예시의 경우 함수에 인자로 객체 리터럴을 바로 전달하기때문에 이는 freshness한 상태로 간주된다.

 

freshness한 오브젝트가 인자로 전달된경우 컴파일러의 예외처리에 걸려 타입 호환을 허용하지 않고 이에 따라 컴파일러가 타입이 호환되지 않다는다고 판단하여 에러가 발생하게 된다.

 

 

실제로 위 코드는 에러가 발생한 코드와 완전히 동일하게 실행되는 코드이지만 aArg라는 객체가 선언되면서 타입이 추론되어 freshness가 사라지게 되며 에러가 발생하지 않는것을 확인할 수 있다.

 

왜 타입스크립트는 그렇다면 굳이 freshness라는것을 만들어 예외를 둔걸까?

아래 예시를 참고해보자

function logIfHasName(something: { name?: string }) {
    if (something.name) {
        console.log(something.name);
    }
}
var person = { name: 'matt', job: 'being awesome' };

logIfHasName(person); // 정상 작동
logIfHasName({neme: 'I just misspelled name to neme'}); // 오류

logIfHasName({test:1, test2:2, test3:3, test4:4}); // ???

여기서 만약 freshness라는것이 존재하지 않는다고 가정하면 2번째 함수 실행 줄에 name 대신 neme이라는 오타가 발생했는데 name이 optional 타입이기 때문에 에러가 발생하지 않게되고 개발자는 이러한 문제를 찾는것에 많은 시간을 소모하게 될 것이다.

 

또한 코드의 마지막 줄에서 보이는 것처럼 개발자의 입장에서 어떠한 함수에 대해 호출문만으로 해당 함수가 실제로 다루는 데이터를 정확하게 알기 힘들다는 문제도 있어 타입스크립트에서는 개발자의 개발 편의를 위해 freshness라는 컨셉을 구현하였다.

심화

private,protected

타입스크립트에서는 class의 private, protected와 같은 멤버도 타입 호환에 영향을 미친다고 말한다.

 

아래의 코드는 멤버가 같기 때문에 정상적으로 동작한다

class A {
  a: string;
  constructor(a:string) {
    this.a = a;
  }
}

class SubClassA extends A {}

class B {
  a: string;
  constructor(a:string) {
    this.a = a;
  }
}

class SubClassB extends B {}

let classA = new A('A')
let classB = new B('B')

let subClassA = new SubClassA('sub A')
let subClassB = new SubClassB('sub B')

classA = classB
classB = classA
classA = subClassB
classB = subClassA

이 때 classA에 private 멤버를 추가하면 어떻게될까

class A {
  a: string;
  private b: number
  constructor(a:string,b:number) {
    this.a = a;
    this.b = b
  }
}

private 멤버임에도 아래 이미지와 같이 에러가 발생하게된다.

 

classB = classA와 같은 경우 A의 멤버에 B의 멤버가 모두 있기때문에 에러가 발생하지 않지만 반대의 경우 b 멤버가 없어 에러가 발생하게된다.

 

에러를 해결하기 위해 동일한 멤버 b를 class B에도 추가해보았다.

class B {
  a: string;
  private b: number
  constructor(a:string,b:number) {
    this.a = a;
    this.b = b
  }
}

 

결과적으로 이는 타입스크립트에서 에러가 발생하게 된다. 클래스 A와 B의 멤버가 같더라도 각 클래스 내에 있는 private 멤버 b가 서로 다른 클래스에서 선언되었기때문에 에러가 발생한다. 이를 정상적으로 동작하게 만들려면 아래 이미지처럼 같은 클래스에서 나온 멤버를 포함해야한다. 이는 protected에도 동일하게 적용된다.

 

generic

아래의 코드는 잘 동작하는 정상적인 코드이다. 이유는 타입스크립트에서 Empty 인터페이스의 프로퍼티가 없기때문에 x타입과 y타입이 같다고 판단하기 때문이다.

interface Empty<T> {}
let x: Empty<number>;
let y: Empty<string>;

x = y; 

그러면 Empty에 프로퍼티를 추가하면 어떻게될까

interface NotEmpty<T> {
  data: T;
}
let x: NotEmpty<number>;
let y: NotEmpty<string>;
x = y; // 오류

에러가 발생하게 된다. 이 경우 NotEmpty 인터페이스 안에 data가 있기 때문에 x와 y의 타입이 달라지기 때문이다.

let afnc = function <T>(x: T): T {
  // ...
  return 1 as any
};
let bfnc= function <U>(y: U): U {
  return 2 as any
};
afnc = bfnc; // 성공
afnc<number> = bfnc<string> // 에러

위 예시에서 afnc = bfnc; 는 암시적으로 any타입으로 제네릭이 지정되기때문에 타입이 같아 에러가 발생하지 않지만 afnc<number> = bfnc<string> 의 경우 타입이 다르다고 판단되어 에러가 발생하게된다.

 

 

'typescript' 카테고리의 다른 글

타입스크립트의 제네릭이란  (1) 2024.03.07