데브먼트 타입스크립트 스터디가 끝나고 최종 발표를 위해 정리한 글 입니다.
우리가 배운 Javascript는 동적 타입 언어입니다. Javascript에서는 컴파일시 타입을 정하지 않고 런타임까지 타입에 대한 결정을 끌고 갈수 있어 유연성이 높습니다.
타입스크립트는 정적 타입 언어입니다. Typescript 뿐 아니라 Java나 C와 같은 정적 타입 언어는 코드를 실행하기 전에 정적으로 변수의 타입을 결정합니다. 때문에 타입에러로 인한 문제를 컴파일 타임에 알 수 있어 안정성이 높습니다.
TypeScript는 정적 타입언어로써의 장점을 가진 동시에 Java, C와 구별되어 동작하는 부분이 존재합니다.
먼저 타입스크립트는 점진적 타이핑을 지원합니다. 아래 예시를 통해 이해해봅시다.
let a : number = 1;
a.toUpperCase() // ERROR!
정적 타입 언어의 특징을 가진 typescript는 두번째 줄에서 에러를 표시합니다. 변수 a를 number 타입으로 정의했기 때문에 a에 toUpperCase 같은 문자열 메서드를 사용할 수 없기 때문입니다.
그렇다면 다음과 같은 상황에서는 어떨까요?
let a = 1;
a.toUpperCase(); // ERROR!
타입스크립트는 여전히 에러를 표시합니다. a의 타입을 지정해주지 않았는데도 말이죠.
타입스크립트는 변수의 타입을 지정하지 않아도 초깃값을 기준으로 타입을 추론해줍니다. 이러한 특징을 통해 우리는 자바스크립트 코드에 타입스크립트를 점진적으로 적용할 수 있습니다.
타입스크립트가 다른 타입시스템과 구별되는 또 다른 점은 구조적 타이핑을 따른다는 점 입니다.
해당 글(발표)에서는 타입스크립트의 구조적(서브) 타이핑의 특징과 이로인해 발생하는 문제와 해결과정, 그리고 이를 거스르는 예시를 본격적으로 다루도록 하겠습니다.
구조적 타이핑을 좀 더 잘 이해하기 위해서 우리는 이와 구별되는 명목적 타이핑에대해서 알 필요가 있습니다.
명목적 타이핑 (혹은 명목적 타입시스템)에서는 값과 객체는 하나의 구체적인 타입을 가지고 있으며 각각의 타입은 이름으로 구분됩니다. 동일한 멤버(필드)를 가지고 있더라도 타입의 이름이 다르다면 다른 타입으로 판단합니다
class Pet {
name: string;
breed: string;
}
class Dog {
name: string;
breed: string;
}
// ❌ 명목적 타이핑 - Not OK
// Dog은 Pet과 서로 다른 이름의 타입을 가지고 있기 때문에 호환불가합니다.
let pet: Pet = new Dog();
반면 구조적 타이핑은 구조, 즉 멤버로 타입을 구분합니다.
class Pet { // name과 breed를 가지고 있다면 Pet 타입 입니다.
name: string;
breed: string;
}
class Dog { // name과 breed, age를 가지고 있다면 Dog 타입입니다.
name: string;
breed: string;
age: number;
}
// ✅ 구조적 타이핑 - OK
// Dog은 Pet과 호환가능한 멤버 "name", "breed"를 가지고 있기 때문에 호환가능합니다.
let pet: Pet = new Dog();
이처럼 타입스크립트에서의 타입시스템은 집합으로 이해할 수 있습니다.
우리는 위 코드를 통해 자연스럽게 타입스크립트의 구조적 서브타이핑을 접하게 되었습니다. Dog타입의 인스턴스가 Pet타입의 pet 변수에 할당이 가능한 이유는 객체의 이름이 아닌 속성으로 타입을 구분했기 때문입니다.
타입스크립트를 지탱하는 중요한 개념인 구조적 서브타이핑은 “이름이 다른 객체라도 가진 속성이 동일하다면(name, breed) 타입 스크립트에서는 호환이 가능하다”라는 개념이죠.
타입스크립트는 명목적 타이핑대신 구조적 타이핑의 개념을 선택함으로써 기존의 javascript로 작성된 코드들이 typescript로 전환하는데 적응력을 높였습니다.
<책내용 참고해서 내용 조금더 보충하기>
요약하자면
1️⃣ 구조적 타이핑은 적당히 엄격하고 적당히 느슨한 타입시스템입니다. (명목적타이핑과 같이 “확실하게 올바른” 타입검사를 목표로 하는 시스템이 아닙니다.)
2️⃣ 타입스크립트가 구조적 타이핑을 사용하는 이유는 Javascript가 가진 언어적 특성 + 하위 호환성을 지키기 위한 슈퍼셋언어라는 디자인 목표 때문입니다.
typescript의 이러한 구조적 타이핑은 때로는 예상치 못한 결과를 야기하기도 합니다. 아래 두가지 예시를 통해 이해해봅시다.
interface Cube {
width: number;
height: number;
depth: number;
}
function addLines(c: Cube) { // 정육면체의 모든 모서리의 합을 구하는 함수
let total = 0;
for (const axis of Object.keys(c)) {
// 🚨 ERROR
const length = c[axis];
total += length;
}
return total;
}
Cube
인터페이스의 모든 멤버는 number타입을 가집니다. 그러나 구조적 타이핑의 특징 때문에 addLine의 인자c에는 width
, height
, depth
와 추가적인 멤버를 가진 객체 또한 들어올 수 있습니다. 위 코드에서는 c[axis]
타입이 string일 수 도 있다는 에러가 발생합니다.
마치 아래와 같은 상황일 때 말이죠!
const namedCube = {
width: 6,
height: 5,
depth: 4,
name: "SweetCube", // string 타입의 추가 속성이 정의되었다
};
addLines(namedCube); // ✅ OK 컴파일 에러가 발생하지 않으나 런타임에서 문제가 발생할 수 있습니다.
즉, 타입스크립트는 c[axis]
가 어떤 속성을 지닐지 알 수 없으며 c[axis]
의 타입을 number
라고 확정할수 없어서 에러를 발생시킵니다.
구조적 타이핑은 명목적 타이핑에 비해 타입 안정성은 떨어지지만 유연합니다.
하지만 이처럼 보다 엄격한 타입안정성을
필요로 할 때가 있습니다. 이제부터 위의 문제를 해결하면서 타입스크립트이 여러 문법을 소개해보도록 하겠습니다.
Typescript에서는
as
키워드를 사용한 타입단업 문법을 통해 타입을 강제할 수 있습니다. 타입단언은 개발자가 해당 값의 타입을 더 잘 파악할 수 있을 때 사용되며 강제 형변환과 유사한 기능을 제공합니다.
interface Cube {
width: number;
height: number;
depth: number;
}
const addLines = (c: Cube) => {
let total = 0;
for (const axis of Object.keys(c)) {
<const length = c[axis as keyof Cube];
total += length;
}
return total;
};
addLines({
width: 10,
height: 10,
depth: 10,
});
첫번째 방법은 얻은 객체의 key에 타입단언을 추가하는 방식입니다.
axis as keyof Cube
는 Cube의 key를 타입으로 가져오게됩니다. 이렇게 되면 c[axis]
는 number만이 존재한다는 것을 추론하게 됩니다.
interface Cube {
width: number;
height: number;
depth: number;
}
const addLines = (c: Cube) => {
let total = 0;
for (const axis of Object.keys(c) as Array<keyof Cube>) {
const length = c[axis];
total += length;
}
return total;
};
addLines({
width: 10,
height: 10,
depth: 10,
});
두번째 방법은 key의 배열에 타입단언을 추가하는 방식입니다.위 예시에서는 Object.keys(c)
로 반환되는 배열의 타입을 지정합니다. as Array<keyof Cube>
제네릭을 이용하여 배열의 각요소가 Cube의 key 타입임을 단언함으로써 동일하게 추론이 가능합니다.
인덱스 시그니처는 속성 이름을 알 수 없지만, 속성값의 타입은 알 수 있을 때 사용합니다.
[key: K]: T
형태로 선언하며, 키는 타입K
, 값은 타입T
를 가집니다.
interface Cube {
width: number;
height: number;
depth: number;
[key:string]: number;
}
function addLines(c: Cube) { // 정육면체의 모든 모서리의 합을 구하는 함수
let total = 0;
for (const axis of Object.keys(c)) {
const length = c[axis];
total += length;
}
return total;
}
Cube
인터페이스에 인덱스 시그니처를 추가하여 c의 멤버 타입을 number로 추론시킬 수 있습니다.
interface Cube {
width: number;
height: number;
depth: number;
}
function addLines(c: Cube) {
// 정육면체의 모든 모서리의 합을 구하는 함수
let total = 0;
for (const axis of Object.keys(c)) {
if (typeof axis === 'number') {
const length = c[axis];
total += length;
}
}
return total;
}
타입가드란 특정 타입을 가질 수 밖에 없는 상황을 유도하는 방식을 통해 타입을 좁히는 방법입니다.
위의 예시에서는 javascript의 typeof연산자를 통해 axis가 number타입인 경우에만 더하기 연산을수행하도록 할 수 있습니다.
단, typeof 연산자는 자바스크립트의 타입시스템만 대응할 수 있습니다. typeof 연산자와 배열을 사용할 경우 object로 판별되어 버리는 것과 같이 복잡한 타입검증에는 명확한 한계를 지닙니다.
✅typeof
연산자는 원시타입을 좁히는 용도로만 사용합시다.
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
function handlePoint2D(point: Point2D) {}
function getPoint3D(): Point3D {
return { x: 0, y: 0, z: 0 };
}
handlePoint2D(getPoint3D()); // ✅ OK
구조적 타이핑은 명시된 타입(Point2D)외에 추가적인 멤버를 갖는 것을 허용합니다.
때문에 Point2D 타입의 인자를 다룰것으로 예상되는 함수에 Point3D 타입의 매개변수를 전달해도 문제가 발생하지 않습니다. 이는 명백한 개발자의 실수이지만 타입스크립트는 이러한 실수를 허용해줍니다.
type Brand<K, T> = K & { _brand: T };
type Point2D = Brand<
{
x: number;
y: number;
},
'Point2D'>;
type Point3D = Brand <{
// 브랜드 멤버
__brand: 'Point3D';
x: number;
y: number;
z: number;
}, 'Point3D'>
function handlePoint2D(point: Point2D) {}
function getPoint3D(): Point3D {
return <Point3D>{ x: 0, y: 0, z: 0 };
}
handlePoint2D(getPoint3D());
// ERROR! Point3D 형식의 인수는 Point2D 형식의 매개 변수에 할당될 수 없습니다.
Branded Type 은 개발자가 매개변수로 정의한 타입 외에는 호환되지 않도록 유니크한 멤버를 추가하여 타입을 강제하는 기법입니다.
위와 같은 예시 외에도 온도나 화폐단위(원,달러, 엔화)와 같이 number
타입이지만 서로 다른 의미를 가질 수 있어 “명시적인” 구분이 필요할 때 사용할 수 있습니다.
interface Point2D {
x: number;
y: number;
}
interface Point3D {
x: number;
y: number;
z: number;
}
function handlePoint2D(point: Point2D) {}
function getPoint3D(): Point3D {
return <Point3D>{ x: 0, y: 0, z: 0 };
}
const point3d = getPoint3D();
handlePoint2D(point3d); // ✅ OK
handlePoint2D({ x: 0, y: 0, z: 0 }); // ERROR!!
가장 마지막 줄의 코드는 왜 타입호환이 되지 않는 것일까요?
타입스크립트에서 함수의 인자로 객체리터럴이 전달될때는 잉여속성체크라는 기능이 발동하게 됩니다.
즉, 객체 리터럴이 전달될때는 잉여속성체크가 발동되어 구조적 서브타이핑이 적용되지 않습니다.
ERROR : 개체 리터럴은 알려진 속성만 지정할 수 있으며
Point2D
형식에z
이(가) 없습니다.
이를 이해하려면 타입스크립트 컴파일러의 동작원리에 대해서 알아야 합니다. ( 더 궁금하다면, 저희 스터디 문서를 참고하셔도 됩니다! 영민님의 멋진 정리
)
간략히 말하자면, TypeScript 컴파일러는 TypeScript 소스코드를 AST (Abstract Syntax Tree)로 변환한 뒤, 타입 검사를 수행하고, 그 후 JavaScript 소스코드로 변환합니다.
타입스크립트 깃허브의 compiler 디렉토리에서 AST를 JavaScript 소스코드로 변환하는 과정의 코드를 살펴볼수 있는데요. 타입호환의 예외가 발생하는 지점의 코드를 살펴보면, 함수에 인자로 들어온 값이 FreshLiteral
인지 아닌지 여부에 따라 조건분기가 발생하여 타입 호환 허용 여부가 결정됩니다.
/** 함수 매개변수에 전달된 값이 FreshLiteral인 경우 true가 됩니다. */
const isPerformingExcessPropertyChecks =
getObjectFlags(source) & ObjectFlags.FreshLiteral;
if (isPerformingExcessPropertyChecks) {
/** 이 경우 아래 로직이 실행되는데,
* hasExcessProperties() 함수는
* excess property가 있는 경우 에러를 반환하게 됩니다.
* 즉, property가 정확히 일치하는 경우만 허용하는 것으로
* 타입 호환을 허용하지 않는 것과 같은 의미입니다. */
if (hasExcessProperties(source as FreshObjectLiteralType)) {
reportError();
}
}
/**
* FreshLiteral이 아닌 경우 위 분기를 skip하게 되며,
* 타입 호환을 허용하게 됩니다. */
타입스크립트는 신선도(Freshness)라는 개념을 사용하여 타입 호환의 예외를 처리합니다.
즉, 객체 리터럴로 전달( handlePoint2D({ x: 0, y: 0, z: 0 });
)할경우 freshness가 지속되어 타입호환이 허용되지 않는것 입니다.
https://www.un-defined.dev/post/typescript/structural-typing
https://toss.tech/article/typescript-type-compatibility
https://www.youtube.com/watch?v=kMuJz6N-Grw