diff --git a/_posts/js/typescript-subtyping.md b/_posts/js/typescript-subtyping.md new file mode 100644 index 00000000..5e395bfe --- /dev/null +++ b/_posts/js/typescript-subtyping.md @@ -0,0 +1,292 @@ +--- +title: 'Object.keys()는 왜 string[] 타입일까?' +description: '구조적 서브 타이핑과 집합적 특징을 알아보자' +url: 'typescript-subtyping' +tags: ['typescript', 'structural-subtyping', 'object-keys'] +coverImage: 'https://github.com/1ilsang/dev/assets/23524849/a56811d2-a7e2-4c71-b36e-d2bc368989b1' +date: '2024-05-10T13:11:19.101Z' +ogImage: + url: 'https://github.com/1ilsang/dev/assets/23524849/a56811d2-a7e2-4c71-b36e-d2bc368989b1' +--- + +![cover](https://github.com/1ilsang/dev/assets/23524849/8105b3d4-e413-4a36-929f-a0a6a1d27aff 'cover') + +## Index + +- [TL;DR!](#tldr) +- [문제](#문제) +- [구조적 서브 타이핑이란](#구조적-서브-타이핑이란) +- [해결](#해결) +- [마무리](#마무리) +- [One more thing](#one-more-thing) +- [참고](#참고) + +## TL;DR! + +타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다. + +`Object.keys()`는 런타임에서의 안정성을 위해 넓은 타입인 `string[]` 타입으로 추론된다. + +## 문제 + +```ts {12} +interface MyObject { + first: number; + second: string; + third: boolean; +} +const parseMyObject = (object: MyObject) => { + const parsed = { accNum: 0, trueCount: 0 }; + + Object.keys(object).forEach((key) => { + // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'. + // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053) + const curValue = object[key]; + if (!isNaN(curValue)) { + parsed.accNum += curValue; + } else if (curValue === true) { + parsed.trueCount++; + } + }); + return parsed; +}; +``` + +개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 [Object.keys](https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object/keys) 메서드를 사용하면 항상 `key` 값이 `string`으로 추론되는 것을 확인할 수 있다. + +
+ +```ts +const keyList = Object.keys(obj) as Array; +``` + +`string`으로 [타입 추론(Type Inference)](https://www.typescriptlang.org/docs/handbook/type-inference.html) 되었기 때문에 이후 코딩의 편의성을 위해 다시 [타입 단언(Type Assertion)](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions)을 하게 된다. + +단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 [제너릭 타입](https://www.typescriptlang.org/ko/docs/handbook/2/generics.html)으로라도 추론하고 싶어 진다. + +
+ +```ts title="TypeScript/src/lib/es2015.core.d.ts" +interface ObjectConstructor { + keys(o: {}): string[]; +} +``` + +하지만 타입스크립트는 [Object.keys\() 제너릭 타입을 제공하지 않는다](https://github.com/microsoft/TypeScript/blob/36ac4eb700ce596033762b821545753753d13444/src/lib/es2015.core.d.ts#L301-L305). + +
+ +여기서 의문이 생긴다. + +- 기존의 key 타입을 왜 추론하지 못할까? +- 제너릭 타입은 왜 제공하지 않았을까? + +
+ +오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다. + +## 구조적 서브 타이핑이란 + +앞에서 다룬 문제의 이유는 [타입스크립트가 구조적 서브 타이핑을 기반](https://www.typescriptlang.org/docs/handbook/type-compatibility.html)으로 하고 있기 때문이다. + +이전에 [우아한 타입스크립트](/posts/woowa-type-review#%ED%83%80-%EC%96%B8%EC%96%B4%EC%9D%98-%ED%83%80%EC%9E%85-%EC%8B%9C%EC%8A%A4%ED%85%9C%EA%B3%BC-%EB%B9%84%EA%B5%90)에서 구조적 타이핑을 잠깐 이야기한 적이 있었다. + +![duck typing](https://github.com/1ilsang/dev/assets/23524849/ff217791-2571-4ecf-9494-b95905d77311) + +> Image Source: [What is duck typing](https://stackoverflow.com/questions/4205130/what-is-duck-typing) + +자바스크립트는 [덕 타이핑](https://ko.wikipedia.org/wiki/%EB%8D%95_%ED%83%80%EC%9D%B4%ED%95%91)을 기반으로 하는 [동적 타이핑](https://developer.mozilla.org/ko/docs/Glossary/Dynamic_typing) 언어이다. + +따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제([정적 타이핑](https://developer.mozilla.org/ko/docs/Glossary/Static_typing))하기 위한 고민을 하게 된다. + +
+ +```ts +type Book = { + name: string; +}; +``` + +위와 같은 객체 타입 `Book`을 선언하게 되면 일반적인 [명목적 타입 시스템](https://ko.wikipedia.org/wiki/%EB%AA%85%EB%AA%A9%EC%A0%81_%EC%9E%90%EB%A3%8C%ED%98%95_%EC%B2%B4%EA%B3%84)에서는 반드시 `Book { name: string }` 형태의 타입만 와야 한다. + +
+ +```ts +const getName = (book: Book) => { + return book.name; +}; + +const book1 = { name: '123' }; +const book2 = { name: '123', model: 'wow' }; +const book3 = { name: '123', model: 'wow', wow: 'line' }; + +getName(book1); // OK +getName(book2); // OK +getName(book3); // OK +``` + +하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다. + +구조적 타입 시스템의 주요 특성은 **값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다**는 것이다. + +따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다. + +그렇다면 구조적 서브 타이핑과 `Object.keys`의 반환 타입에는 어떤 연관이 있는 것일까? + +
+ +```ts +class MyObject { + // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript + // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다. + keys(o: T): (keyof T)[]; +} +const keys = MyObject.keys(book1); // "name"[] +const keys = MyObject.keys(book2); // "name"[] +const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[] +``` + +자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다. + +그렇기 때문에 타입스크립트는 객체 인자에 **`T` 타입의 값만 존재한다는 보장을 할 수 없다**. + +```ts +for (const key of Object.keys(book1)) { + // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053) + const value = book1[key]; +} +``` + +따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 `(keyof T)[]`가 아닌 넓은 타입인 `string[]`으로 추론한다. + +관련 논의는 [#12253](https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208) 이슈 코멘트에서 확인할 수 있다. + +- [글의 말미](/posts/typescript-subtyping#one-more-thing)에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다. + +## 해결 + +이제 타입스크립트가 `Object.keys`의 값을 `string[]`으로 추론하는 이유를 확인했다. + +이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까? + +### 타입 가드를 통한 타입 좁히기 + +```ts +const book: Book = { name: '123' }; +const book3 = { name: '123', model: 'wow', wow: 'line' }; + +// 타입 좁히기 +const isBook = (key: string): key is keyof Book => { + return Object.keys(book).includes(key); +}; + +for (const key of Object.keys(book3)) { + // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨 + if (isBook(key)) { + // Book 타입의 키 + } else { + // 구조적 서브 타이핑으로 확장된 키 + } +} +``` + +[타입 가드(Type Guards)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#typeof-type-guards)를 통한 [타입 좁히기(Type Narrowing)](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다. + +무엇보다 런타임에서도 안전한 코드로 변화했다. + +## ONE MORE THING + +```ts +type Book = { name: string }; +type Car = { model: string }; + +const BookOrCar = {} as Book | Car; +// 🚨 Property 'name' does not exist on type 'BookOrCar'. +// Property 'name' does not exist on type 'Car'.(2339) +BookOrCar.name; +// 🚨 Property 'model' does not exist on type 'BookOrCar'. +// Property 'model' does not exist on type 'Book'.(2339) +BookOrCar.model; + +const BookAndCar = {} as Book & Car; +BookAndCar.name; // string +BookAndCar.model; // string + +type A = 'A'; +type B = 'B'; + +type AorB = A | B; // 'A' | 'B' +type AandB = A & B; // never +``` + +구조적 서브 타이핑을 조금 더 알아보자. + +`Book` 타입과 `Car` 타입이 [유니온](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#union-types) 혹은 [교차](https://www.typescriptlang.org/docs/handbook/2/objects.html#intersection-types) 될 때 타입 추론이 혼란스러운 부분이 있다. + +`BookOrCar`는 `{ name: string }` 혹은 `{ model: string }` 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다. + +반대로 교차 타입은 `BookAndCar`에서는 모든 값을 가지지만 `AandB`에서는 `never` 타입이 추론된다. + +
+ +![what](https://github.com/1ilsang/dev/assets/23524849/eb6ea24b-99e3-4ee0-b97e-ff2c6c78786e 's') + +앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다. + +각 타입을 값의 집합으로 나열해 보자. + +
+ +```json title="Book 타입에 충족되는 값의 집합" +{ name: "123" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `name`이 존재하는 객체 +``` + +```json title="Car 타입에 충족되는 값의 집합" +{ model: "wow" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `model`이 존재하는 객체 +``` + +```json title="Book | Car 타입의 모든 값의 집합" +{ name: "123" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `name`이 존재하는 객체 +{ model: "wow" }; +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +그 외 `model`이 존재하는 객체 +``` + +`Book | Car`의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(`name` 혹은 `model`이 반드시 있어야 하는 경우가 없음) 할 수 있다. + +
+ +```json title="Book & Car 타입의 모든 값의 집합" +{ name: "123", model: "wow" }; +{ name: "123", model: "wow", wow: "line" }; +``` + +반면 `Book & Car`의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다. + +### 결론 + +따라서 `Book | Car`에서는 항상 존재하는 값이 없기 때문에 `name`, `model` 어느 값도 존재하지 않게 되지만 `Book & Car`에서는 `name`, `model` 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다. + +## 마무리 + +타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다. + +타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다. + +## 참고 + +- [Type Compatibility](https://www.typescriptlang.org/ko/docs/handbook/type-compatibility.html) +- [[번역] 왜 타입스크립트는 Object.keys의 타입을 적절하게 추론하지 못할까요?](https://medium.com/@yujso66/번역-왜-타입스크립트는-object-keys의-타입을-적절하게-추론하지-못할까요-477253b1aafa) +- [Why Object.keys Returns an Array of Strings in TypeScript (And How To Fix It)](https://www.mattstobbs.com/object-keys-typescript/) +- [타입스크립트의 구조적 타이핑](https://www.yongdam.sh/blog/effective-typescript-structural-typing) +- [Object.keys() types refinement, and Object.entries() types bugfix #12253](https://github.com/microsoft/TypeScript/pull/12253#issuecomment-263132208) diff --git a/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html new file mode 100644 index 00000000..0cb0d46e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/desktop/typescript-subtyping.html @@ -0,0 +1,189 @@ +

Object.keys()는 왜 string[] 타입일까?

1ilsang
1ilsang
클라이밍 하실래염?
published

cover

+

Index

+ +

TL;DR!

+

타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다.

+

Object.keys()는 런타임에서의 안정성을 위해 넓은 타입인 string[] 타입으로 추론된다.

+

문제

+
interface MyObject {
+  first: number;
+  second: string;
+  third: boolean;
+}
+const parseMyObject = (object: MyObject) => {
+  const parsed = { accNum: 0, trueCount: 0 };
+ 
+  Object.keys(object).forEach((key) => {
+    // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'.
+    // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053)
+    const curValue = object[key];
+    if (!isNaN(curValue)) {
+      parsed.accNum += curValue;
+    } else if (curValue === true) {
+      parsed.trueCount++;
+    }
+  });
+  return parsed;
+};
+

개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 Object.keys 메서드를 사용하면 항상 key 값이 string으로 추론되는 것을 확인할 수 있다.

+
+
const keyList = Object.keys(obj) as Array<keyof typeof obj>;
+

string으로 타입 추론(Type Inference) 되었기 때문에 이후 코딩의 편의성을 위해 다시 타입 단언(Type Assertion)을 하게 된다.

+

단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 제너릭 타입으로라도 추론하고 싶어 진다.

+
+
TypeScript/src/lib/es2015.core.d.ts
interface ObjectConstructor {
+  keys(o: {}): string[];
+}
+

하지만 타입스크립트는 Object.keys<T>() 제너릭 타입을 제공하지 않는다.

+
+

여기서 의문이 생긴다.

+
    +
  • 기존의 key 타입을 왜 추론하지 못할까?
  • +
  • 제너릭 타입은 왜 제공하지 않았을까?
  • +
+
+

오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다.

+

구조적 서브 타이핑이란

+

앞에서 다룬 문제의 이유는 타입스크립트가 구조적 서브 타이핑을 기반으로 하고 있기 때문이다.

+

이전에 우아한 타입스크립트에서 구조적 타이핑을 잠깐 이야기한 적이 있었다.

+

duck typing

+
+

Image Source: What is duck typing

+
+

자바스크립트는 덕 타이핑을 기반으로 하는 동적 타이핑 언어이다.

+

따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제(정적 타이핑)하기 위한 고민을 하게 된다.

+
+
type Book = {
+  name: string;
+};
+

위와 같은 객체 타입 Book을 선언하게 되면 일반적인 명목적 타입 시스템에서는 반드시 Book { name: string } 형태의 타입만 와야 한다.

+
+
const getName = (book: Book) => {
+  return book.name;
+};
+ 
+const book1 = { name: '123' };
+const book2 = { name: '123', model: 'wow' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+getName(book1); // OK
+getName(book2); // OK
+getName(book3); // OK
+

하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다.

+

구조적 타입 시스템의 주요 특성은 값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다는 것이다.

+

따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다.

+

그렇다면 구조적 서브 타이핑과 Object.keys의 반환 타입에는 어떤 연관이 있는 것일까?

+
+
class MyObject {
+  // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript
+  // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다.
+  keys<T extends object>(o: T): (keyof T)[];
+}
+const keys = MyObject.keys<Book>(book1); // "name"[]
+const keys = MyObject.keys<Book>(book2); // "name"[]
+const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[]
+

자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다.

+

그렇기 때문에 타입스크립트는 객체 인자에 T 타입의 값만 존재한다는 보장을 할 수 없다.

+
for (const key of Object.keys(book1)) {
+  // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053)
+  const value = book1[key];
+}
+

따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 (keyof T)[]가 아닌 넓은 타입인 string[]으로 추론한다.

+

관련 논의는 #12253 이슈 코멘트에서 확인할 수 있다.

+
    +
  • 글의 말미에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다.
  • +
+

해결

+

이제 타입스크립트가 Object.keys의 값을 string[]으로 추론하는 이유를 확인했다.

+

이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까?

+

타입 가드를 통한 타입 좁히기

+
const book: Book = { name: '123' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+// 타입 좁히기
+const isBook = (key: string): key is keyof Book => {
+  return Object.keys(book).includes(key);
+};
+ 
+for (const key of Object.keys(book3)) {
+  // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨
+  if (isBook(key)) {
+    // Book 타입의 키
+  } else {
+    // 구조적 서브 타이핑으로 확장된 키
+  }
+}
+

타입 가드(Type Guards)를 통한 타입 좁히기(Type Narrowing)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다.

+

무엇보다 런타임에서도 안전한 코드로 변화했다.

+

ONE MORE THING

+
type Book = { name: string };
+type Car = { model: string };
+ 
+const BookOrCar = {} as Book | Car;
+// 🚨 Property 'name' does not exist on type 'BookOrCar'.
+// Property 'name' does not exist on type 'Car'.(2339)
+BookOrCar.name;
+// 🚨 Property 'model' does not exist on type 'BookOrCar'.
+// Property 'model' does not exist on type 'Book'.(2339)
+BookOrCar.model;
+ 
+const BookAndCar = {} as Book & Car;
+BookAndCar.name; // string
+BookAndCar.model; // string
+ 
+type A = 'A';
+type B = 'B';
+ 
+type AorB = A | B; // 'A' | 'B'
+type AandB = A & B; // never
+

구조적 서브 타이핑을 조금 더 알아보자.

+

Book 타입과 Car 타입이 유니온 혹은 교차 될 때 타입 추론이 혼란스러운 부분이 있다.

+

BookOrCar{ name: string } 혹은 { model: string } 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다.

+

반대로 교차 타입은 BookAndCar에서는 모든 값을 가지지만 AandB에서는 never 타입이 추론된다.

+
+

what

+

앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다.

+

각 타입을 값의 집합으로 나열해 보자.

+
+
Book 타입에 충족되는 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+
Car 타입에 충족되는 값의 집합
{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+
Book | Car 타입의 모든 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+

Book | Car의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(name 혹은 model이 반드시 있어야 하는 경우가 없음) 할 수 있다.

+
+
Book & Car 타입의 모든 값의 집합
{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+

반면 Book & Car의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다.

+

결론

+

따라서 Book | Car에서는 항상 존재하는 값이 없기 때문에 name, model 어느 값도 존재하지 않게 되지만 Book & Car에서는 name, model 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다.

+

마무리

+

타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다.

+

타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
\ No newline at end of file diff --git a/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html new file mode 100644 index 00000000..0cb0d46e --- /dev/null +++ b/e2e/__snapshots__/post/dom.spec.ts/mobile/typescript-subtyping.html @@ -0,0 +1,189 @@ +

Object.keys()는 왜 string[] 타입일까?

1ilsang
1ilsang
클라이밍 하실래염?
published

cover

+

Index

+ +

TL;DR!

+

타입스크립트는 자바스크립트의 덕 타입을 표현하기 위해 구조적 서브 타이핑을 채택하고 있다.

+

Object.keys()는 런타임에서의 안정성을 위해 넓은 타입인 string[] 타입으로 추론된다.

+

문제

+
interface MyObject {
+  first: number;
+  second: string;
+  third: boolean;
+}
+const parseMyObject = (object: MyObject) => {
+  const parsed = { accNum: 0, trueCount: 0 };
+ 
+  Object.keys(object).forEach((key) => {
+    // 🚨 Element implicitly has an 'any' type because expression of type 'string' can't be used to index type 'MyObject'.
+    // No index signature with a parameter of type 'string' was found on type 'MyObject'.(7053)
+    const curValue = object[key];
+    if (!isNaN(curValue)) {
+      parsed.accNum += curValue;
+    } else if (curValue === true) {
+      parsed.trueCount++;
+    }
+  });
+  return parsed;
+};
+

개발을 하다 보면 자연스럽게 객체를 많이 사용하게 된다. 이때 Object.keys 메서드를 사용하면 항상 key 값이 string으로 추론되는 것을 확인할 수 있다.

+
+
const keyList = Object.keys(obj) as Array<keyof typeof obj>;
+

string으로 타입 추론(Type Inference) 되었기 때문에 이후 코딩의 편의성을 위해 다시 타입 단언(Type Assertion)을 하게 된다.

+

단언을 통한 타입 제어가 마음을 불편하게 하기 때문에 제너릭 타입으로라도 추론하고 싶어 진다.

+
+
TypeScript/src/lib/es2015.core.d.ts
interface ObjectConstructor {
+  keys(o: {}): string[];
+}
+

하지만 타입스크립트는 Object.keys<T>() 제너릭 타입을 제공하지 않는다.

+
+

여기서 의문이 생긴다.

+
    +
  • 기존의 key 타입을 왜 추론하지 못할까?
  • +
  • 제너릭 타입은 왜 제공하지 않았을까?
  • +
+
+

오늘은 타입스크립트를 사용하면서 만나는 미묘한 당혹스러움에 대해 파헤쳐 보고자 한다.

+

구조적 서브 타이핑이란

+

앞에서 다룬 문제의 이유는 타입스크립트가 구조적 서브 타이핑을 기반으로 하고 있기 때문이다.

+

이전에 우아한 타입스크립트에서 구조적 타이핑을 잠깐 이야기한 적이 있었다.

+

duck typing

+
+

Image Source: What is duck typing

+
+

자바스크립트는 덕 타이핑을 기반으로 하는 동적 타이핑 언어이다.

+

따라서 타입스크립트는 자바스크립트의 특성(유연한 동적 타입)을 해치지 않으면서 타입을 강제(정적 타이핑)하기 위한 고민을 하게 된다.

+
+
type Book = {
+  name: string;
+};
+

위와 같은 객체 타입 Book을 선언하게 되면 일반적인 명목적 타입 시스템에서는 반드시 Book { name: string } 형태의 타입만 와야 한다.

+
+
const getName = (book: Book) => {
+  return book.name;
+};
+ 
+const book1 = { name: '123' };
+const book2 = { name: '123', model: 'wow' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+getName(book1); // OK
+getName(book2); // OK
+getName(book3); // OK
+

하지만 타입스크립트에서는 위와 같은 모든 형태의 객체가 가능하다. 이것이 바로 구조적 서브 타이핑이다.

+

구조적 타입 시스템의 주요 특성은 값을 할당할 때 정의된 타입에 필요한 속성을 가지고 있다면 호환된다는 것이다.

+

따라서 구조적 타입 시스템에서 타입은 값의 집합으로 생각하면 된다.

+

그렇다면 구조적 서브 타이핑과 Object.keys의 반환 타입에는 어떤 연관이 있는 것일까?

+
+
class MyObject {
+  // https://stackoverflow.com/questions/49464634/difference-between-object-and-object-in-typescript
+  // object 타입은 원시 타입을 제외한 모든 값이 될 수 있다.
+  keys<T extends object>(o: T): (keyof T)[];
+}
+const keys = MyObject.keys<Book>(book1); // "name"[]
+const keys = MyObject.keys<Book>(book2); // "name"[]
+const keys = MyObject.keys(book3); // ("name" | "model" | "wow")[]
+

자바스크립트의 덕 타입 덕에 객체는 런타임 단계에서 더 많은 속성을 가질 수 있다. 또한 구조적 서브 타이핑은 필요한 속성을 가지고 있다면 확장된 집합과 호환되며 에러를 노출하지 않는다.

+

그렇기 때문에 타입스크립트는 객체 인자에 T 타입의 값만 존재한다는 보장을 할 수 없다.

+
for (const key of Object.keys(book1)) {
+  // 🚨 No index signature with a parameter of type 'string' was found on type 'Book'.(7053)
+  const value = book1[key];
+}
+

따라서 타입스크립트는 런타임에서의 안정성을 찾기 위해 좁은 타입의 (keyof T)[]가 아닌 넓은 타입인 string[]으로 추론한다.

+

관련 논의는 #12253 이슈 코멘트에서 확인할 수 있다.

+
    +
  • 글의 말미에서 구조적 서브 타이핑에 대해 더 다루도록 하겠다.
  • +
+

해결

+

이제 타입스크립트가 Object.keys의 값을 string[]으로 추론하는 이유를 확인했다.

+

이를 타입 단언을 사용하지 않고 추론하려면 어떻게 해야 할까?

+

타입 가드를 통한 타입 좁히기

+
const book: Book = { name: '123' };
+const book3 = { name: '123', model: 'wow', wow: 'line' };
+ 
+// 타입 좁히기
+const isBook = (key: string): key is keyof Book => {
+  return Object.keys(book).includes(key);
+};
+ 
+for (const key of Object.keys(book3)) {
+  // 타입 가드로 타입이 존재하는 컨디션 블록이 생기게 됨
+  if (isBook(key)) {
+    // Book 타입의 키
+  } else {
+    // 구조적 서브 타이핑으로 확장된 키
+  }
+}
+

타입 가드(Type Guards)를 통한 타입 좁히기(Type Narrowing)를 활용하면 타입 단언을 하지 않아도 적절하게 타입을 추론할 수 있게 된다.

+

무엇보다 런타임에서도 안전한 코드로 변화했다.

+

ONE MORE THING

+
type Book = { name: string };
+type Car = { model: string };
+ 
+const BookOrCar = {} as Book | Car;
+// 🚨 Property 'name' does not exist on type 'BookOrCar'.
+// Property 'name' does not exist on type 'Car'.(2339)
+BookOrCar.name;
+// 🚨 Property 'model' does not exist on type 'BookOrCar'.
+// Property 'model' does not exist on type 'Book'.(2339)
+BookOrCar.model;
+ 
+const BookAndCar = {} as Book & Car;
+BookAndCar.name; // string
+BookAndCar.model; // string
+ 
+type A = 'A';
+type B = 'B';
+ 
+type AorB = A | B; // 'A' | 'B'
+type AandB = A & B; // never
+

구조적 서브 타이핑을 조금 더 알아보자.

+

Book 타입과 Car 타입이 유니온 혹은 교차 될 때 타입 추론이 혼란스러운 부분이 있다.

+

BookOrCar{ name: string } 혹은 { model: string } 타입이 되기 때문에 두 값이 공존해야 한다고 느껴진다. 하지만 타입스크립트는 두 값 모두 추론하지 못한다.

+

반대로 교차 타입은 BookAndCar에서는 모든 값을 가지지만 AandB에서는 never 타입이 추론된다.

+
+

what

+

앞에서 "구조적 타입 시스템에서의 타입은 값의 집합으로 생각하면 된다"고 했다.

+

각 타입을 값의 집합으로 나열해 보자.

+
+
Book 타입에 충족되는 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+
Car 타입에 충족되는 값의 집합
{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+
Book | Car 타입의 모든 값의 집합
{ name: "123" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `name`이 존재하는 객체
+{ model: "wow" };
+{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+그 외 `model`이 존재하는 객체
+

Book | Car의 경우에 Book 혹은 Car 중 "항상 존재하는 값"이 없는 것을 확인(name 혹은 model이 반드시 있어야 하는 경우가 없음) 할 수 있다.

+
+
Book & Car 타입의 모든 값의 집합
{ name: "123", model: "wow" };
+{ name: "123", model: "wow", wow: "line" };
+

반면 Book & Car의 경우 "항상 존재하는 값"이 있는 것을 확인할 수 있다.

+

결론

+

따라서 Book | Car에서는 항상 존재하는 값이 없기 때문에 name, model 어느 값도 존재하지 않게 되지만 Book & Car에서는 name, model 모두 항상 존재하기 때문에 두 값 모두 존재하게 된다.

+

마무리

+

타입스크립트를 사용하면서도 언어의 근본적인 철학을 이해하지 못한 상태로 작업한 것 같아 반성하게 되는 계기였다.

+

타입을 사용하면서 지금처럼 당혹스러운 부분이 있었는데 이번 기회에 많이 이해할 수 있었다.

+

참고

+
📮 이 포스트에 관심 있으신가요? 이슈를 남겨주세요! 👍
\ No newline at end of file diff --git a/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png new file mode 100644 index 00000000..d953bc01 Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/desktop/typescript-subtyping.png differ diff --git a/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png new file mode 100644 index 00000000..4d9f0f8d Binary files /dev/null and b/e2e/__snapshots__/post/screen.spec.ts/mobile/typescript-subtyping.png differ diff --git a/e2e/__snapshots__/posts.spec.ts/desktop/posts.html b/e2e/__snapshots__/posts.spec.ts/desktop/posts.html index 37e05cd9..3493ddbf 100644 --- a/e2e/__snapshots__/posts.spec.ts/desktop/posts.html +++ b/e2e/__snapshots__/posts.spec.ts/desktop/posts.html @@ -1 +1 @@ -
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file +
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file diff --git a/e2e/__snapshots__/posts.spec.ts/desktop/posts.png b/e2e/__snapshots__/posts.spec.ts/desktop/posts.png new file mode 100644 index 00000000..9375ee55 Binary files /dev/null and b/e2e/__snapshots__/posts.spec.ts/desktop/posts.png differ diff --git a/e2e/__snapshots__/posts.spec.ts/mobile/posts.html b/e2e/__snapshots__/posts.spec.ts/mobile/posts.html index 37e05cd9..3493ddbf 100644 --- a/e2e/__snapshots__/posts.spec.ts/mobile/posts.html +++ b/e2e/__snapshots__/posts.spec.ts/mobile/posts.html @@ -1 +1 @@ -
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file +
JavaScriptRustActivityBookRetrospectToolAlgorithm
\ No newline at end of file diff --git a/e2e/__snapshots__/posts.spec.ts/mobile/posts.png b/e2e/__snapshots__/posts.spec.ts/mobile/posts.png new file mode 100644 index 00000000..1716c95e Binary files /dev/null and b/e2e/__snapshots__/posts.spec.ts/mobile/posts.png differ diff --git a/e2e/post/common.spec.ts b/e2e/post/common.spec.ts index 378f94ff..a8d0ea0a 100644 --- a/e2e/post/common.spec.ts +++ b/e2e/post/common.spec.ts @@ -3,7 +3,7 @@ import { urls } from './utils'; test.describe('common', () => { test('Check all post count', () => { - const ALL_POST_COUNT = 33; + const ALL_POST_COUNT = 34; expect(urls.length).toEqual(ALL_POST_COUNT); }); }); diff --git a/e2e/posts.spec.ts b/e2e/posts.spec.ts index 890697f7..cce18777 100644 --- a/e2e/posts.spec.ts +++ b/e2e/posts.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; import { MACRO_SUITE } from './shared/constants'; -import { gotoUrl } from './shared/utils'; +import { gotoUrl, screenshotFullPage } from './shared/utils'; test.describe('posts', () => { test(MACRO_SUITE.DOM_SNAPSHOT, async ({ page }) => { @@ -8,4 +8,8 @@ test.describe('posts', () => { const body = await page.locator('#__next').innerHTML(); expect(body).toMatchSnapshot([`posts.html`]); }); + + test(MACRO_SUITE.SCREEN_SNAPSHOT, async ({ page }) => { + await screenshotFullPage({ page, url: '/posts', arg: [`posts.png`] }); + }); });