본문 바로가기

FrontEnd

타입스크립트의 타입 불건전성에 대하여 1 : 타입시스템의 한계

반응형

해당 페이지를 번역 및 요약함. https://effectivetypescript.com/2021/05/06/unsoundness/

 

Effective TypeScript › 62 Specific Ways to Improve Your TypeScript

Effective TypeScript: 62 Specific Ways to Improve Your TypeScript

effectivetypescript.com

2부 : 타입스크립트의 타입 불건전성에 대하여 2 : 공식 예제 코드 살펴보기 (tistory.com)

 

타입스크립트의 타입 불건전성에 대하여 2 : 공식 예제 코드 살펴보기

https://www.typescriptlang.org/play?strictFunctionTypes=false#example/soundness TS Playground - An online editor for exploring TypeScript and JavaScript The Playground lets you write TypeScript or J..

itchallenger.tistory.com

 

타입스크립트는 타입 안정성을 위해 사용한다.

혹은 객체 지향을 강화하게 사용한다.

 

그런데 타입 안정성을 위해 사용하는 타입스크립트에는 기본적으로 허용되는 타입 불건전성(unsoundness)가 있다.

대표적으로는 any, 타입 단언, 함수 오버로딩이 있다.

따라서 해당 부분을 의도적으로 주의할 필요가 있다.

https://www.typescriptlang.org/ko/docs/handbook/type-compatibility.html

 

TypeScript의 타입 시스템은 컴파일 시간에 확인할 수 없는 특정 작업을 안전하게 수행할 수 있습니다. 타입 시스템이 이런 특성을 갖고 있을 때, “건전”하지 않다고 말합니다. TypeScript에서 건전하지 못한 곳을 허용하는 부분을 신중하게 고려했으며, 이 문서 전체에서 이러한 상황이 발생하는 곳과 유발하는 시나리오에 대해 설명합니다.

해당 글의 작성자는 이렇게 말한다.

대략적으로 말하면 모든 기호의 정적 유형이 런타임 값과 호환되도록 보장되는 경우 언어는 건전합니다.
const x = Math.random();
// type is number

즉 x:number이고, x의 값이 math.random()이면 x는 건전하다.

더 정확한 타입은  [0, 1)이라 할 수 있으나, 이정도면 충분하다.

타입스크립트는 그 이상으로 값을 더 정확하게 타이핑할 방법도 없으며, 건전성은 Precision(정밀도)에 대한 이야기다.

건전성은 Precision(값들이 얼마나 서로 가까운가)에 대한 이야기다. Accuracy는 측정값이 실제로 얼마나 실제 값에 가까운가에 대한 이야기다.

여기에 불건전성의 예시가 있다.

예시 : T[]

const xs = [0, 1, 2];  // type is number[]
const x = xs[3];  // type is number, but value is undefined;
x의 정적 유형은 숫자로 유추되지만 런타임에 그 값은 숫자가 아닌 undefined 이다. 따라서 이것은 불건전하다.
위 예시처럼 타입스크립트는 건전하지 않다.

하지만 건전성이 타입스크립트의 목적은 아니다. 타입스크립트는 기본 자바스크립트와의 호환성을 우선한다.

우리는 타입스크립트의 불건전성을 이해하고, 런타임 충돌 문제를 피하는 것이 좋다.

많은 프로그래밍 언어는 스스로 sound하다 주장한다. 하지만 자바와 스칼라도 sound하지 않다.

예시 : any

any는 의도적으로 피할 수 있으나, any를 리턴하는 몇몇 기능들을 사용할 때는 피하기 어렵다. (JSON.parse)

JSON.parse("{a:1}") // 무조건 애니를 리턴함.

function alertNumber(x: number) {
  alert(x.toFixed(1));  // static type of x is number, runtime type is string
}
const num: any = 'forty two';
alertNumber(num);
// no error, throws at runtime:
// Cannot read property 'toFixed' of undefined

effective typescript의 5장에서는 any를 어떻게 안전하게 사용하는지에 대해 다루고 있다고 한다 (unknown을 대안으로).

궁금하면 살펴보자.

예시 : Type Assertion (not the "cast",  rant on this terminology):

꽤 자주 사용하게 되는 기능이다. 

function alertNumber(x: number) {
  alert(x.toFixed(1));
}
const x1 = Math.random() || null;  // type is number | null
// 주 : random이 0이면 null이 된다.
alertNumber(x1);
//          ~~ ... Type 'null' is not assignable to type 'number'.
alertNumber(x1 as number);  // type checks, but might blow up at runtime. 주 : 정적 타입 체크는 피해가지만. 런타임에 오류 발생 가능.

위에 주석에도 적어뒀는데, Math.random이 0 이면 null이 되어 에러가 발생한다.

 

타입 단언은 api 호출 시에도 많이 사용한다.

const response = await fetch('/api/fun-fact');
const fact = await response.json() as FunFact;

타입 단언 없애는 방법 1 : if로 축소

const x1 = Math.random() || null;  // type is number | null
if (x1 !== null) {
  alertNumber(x1);  // ok, x1's type is number
}

타입 단언 없애는 방법 2 : 타입 가드

function isFunFact(data: unknown): data is FunFact {
  return data && typeof data === 'object' && 'fact' in data /* && ... */;
}

const response = await fetch('/api/fun-fact');
const fact = await response.json();
if (!isFunFact(fact)) {
  throw new Error(`Either it wasn't a fact or it wasn't fun`);
}
// type of fact is now FunFact!

타입 가드를 대신할 방법들 

Zod , typescript-json-schema , crosswalk 

 

예시 : 객체와 배열 조회

매우 자주 사용한다.

TypeScript는 배열 조회에서 어떤 종류의 경계 검사도 수행하지 않으며, 이는 불건전함 및 런타임 오류로 직접 이어질 수 있다.
const xs = [1, 2, 3];
const x = xs[3];  // static type is number but runtime type is undefined.
alert(x.toFixed(1));
// no error, throws at runtime:
// Cannot read property 'toFixed' of undefined

인덱스 타입 객체도 마찬가지다.

type IdToName = { [id: string]: string };
const ids: IdToName = {'007': 'James Bond'};
const agent = ids['008'];  // static type is string but runtime type is undefined.
TypeScript가 이런 종류의 코드를 허용하는 이유는 무엇일까? 
해당 방식은 매우 일반적이고, 특정 인덱스/배열 액세스가 유효한지 여부를 증명하기가 매우 어렵기 때문입니다.
TypeScript의 noUncheckedIndexedAccess 옵션이 있음.
이 기능을 켜면 첫 번째 예에서 오류를 발견하지만 유효한 코드에도 플래그를 지정함. (오탐)
const xs = [1, 2, 3];
const x3 = xs[3]; // static type is number | undefined
alert(x3.toFixed(1));
//    ~~ Object is possibly 'undefined'.
const x2 = xs[2]; // static type is number | undefined
alert(x2.toFixed(1));
//    ~~ Object is possibly 'undefined'.

몇몇 문법을 사용하는 경우는 똑똑하게 작동함.

const xs = [1, 2, 3];
for (const x of xs) {
  console.log(x.toFixed(1));  // ok
}
const squares = xs.map(x => x * x);  // also ok

아래와 같은 방법도 있는데 이 방법의 단점은 딱히 말하지 않겠음. (주 : 진짜 undefined가 가능함)

const xs: (number | undefined)[] = [1, 2, 3];
const x3 = xs[3]; // static type is number | undefined
alert(x3.toFixed(1));
//    ~~ Object is possibly 'undefined'.

type IdToName = { [id: string]: string | undefined };
const ids: IdToName = {'007': 'James Bond'};
const agent = ids['008'];  // static type is string | undefined
alert(agent.toUpperCase());
//    ~~~~~ Object is possibly 'undefined'.

다른 방법은 배열 , 속성 조회를 피하는 것이다. (객체 참조로 전달)

interface MenuProps {
  menuItems: {[id: string]: MenuItem};
  onSelectItem: (menuItem: MenuItem) => void;
}

예시 : 부정확한 타입 정의

@types로 시작하는 라이브러리들은 js로 작성되어 tsc로 추론된 파일들일 경우가 대부분이다.

애초부터 타입스크립트로 작성한게 아닌 이상 js 동적 타입 시스템의 단점을 가져온다.

해당 버그들은 매우 빨리, 자주 수정되어 보기 어렵지만 발생할 경우 치명적이다.

또한, 역사적인 이유로 부정확한 타입을 추론하는 경우도 있다. https://github.com/microsoft/TypeScript/pull/28553

 

Object.assign return assigned type instead merged by anion155 · Pull Request #28553 · microsoft/TypeScript

Got 'assing' in my eyes. Found some old bugs created in 16 (#11100). So I made this quick PR. Situation: let a = { a: 'string', b: { c: 4 }, }; let b = { a: 5, b: { d: 3 }, } Object.assign(...

github.com

 

예시 : 버라이언스와 배열 - 파라미터 변경에 의한 문제

매우 드뭄. 매우 깊거나 복잡한 배열
아래의 예시는 업캐스팅에 의해 고양이 배열에 개를 넣을 수 있다.
class Animal {}

class Mammal extends Animal {
  isMammal=true;
}

class Cat extends Mammal {
  isCat=true;
  isDog=false;
}

class Dog extends Mammal {
  isDog=true;
  isCat=false;
}

function addDogOrCat(arr: Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat());
}

function getMammals(): Mammal[] {
  return [];
}

// B
function hasCat(arr: Animal[]) {
  return arr.some(e => e instanceof Cat);
}
const x: Mammal[] = getMammals();
const y = hasCat(x);

// C
const z: Cat[] = [new Cat()];
addDogOrCat(z); // // Sometimes puts a Dog in a Cat array, sad!

가장 좋은 해결책은 파라미터를 변경하지 않는 것

function addDogOrCat(arr: readonly Animal[]) {
  arr.push(Math.random() > 0.5 ? new Dog() : new Cat());
  //  ~~~~ Property 'push' does not exist on type 'readonly Animal[]'.
}

아래와 같이 작성한다.

function dogOrCat(): Animal {
  return Math.random() > 0.5 ? new Dog() : new Cat();
}

const z: Cat[] = [new Cat(), dogOrCat()];
//                           ~~~~~~~~~~ error, yay!
// Type 'Animal' is missing the following properties from type 'Cat': ...

함수 인자에서 부수효과 발생

타입 체크는 다 피하지만, 오류가 발생할 수 있는 경우.

(파라미터로 전달한 객체에서 부수효과를 발생시키는 경우 - 이 경우는 자바같은 언어도 마땅한 답이 없다.)

interface FunFact {
  fact: string;
  author?: string;
}

function processFact(fact: FunFact, processor: (fact: FunFact) => void) {
  if (fact.author) {
    processor(fact);
    document.body.innerHTML = fact.author.blink();  // ok
  }
}

// 런타임 오류 발생!
processFact(
  {fact: `Peanuts aren't actually nuts`, author: 'Botanists'},
  f => delete f.author
);

 

파라미터를 불변으로 처리하여 해결한다.

function processFact(fact: FunFact, processor: (fact: Readonly<FunFact>) => void) {
  // ...
}
processFact(
  {fact: `Peanuts aren't actually nuts`, author: 'Botanists'},
  f => delete f.author
  //          ~~~~~~~~
  // The operand of a 'delete' operator cannot be a read-only property.
);

function processFact(fact: FunFact, processor: (fact: FunFact) => void) {
  const {author} = fact;
  if (author) {
    processor(fact);
    document.body.innerHTML = author.blink();  // safe
  }
}

더 읽어볼 자료들

https://frenchy64.github.io/2018/04/07/unsoundness-in-untyped-types.html

 

Are unsound type systems wrong?

This is an essay I wrote early-2016. After battling with soundness vs. usability in Typed Clojure for many years, I was starting to reconsider the “obviously wrong” approach of baking unsoundness into the type system from the get-go. I look at some his

frenchy64.github.io

A Note on Soudness

 

Documentation - Type Compatibility

How type-checking works in TypeScript

www.typescriptlang.org

 

추가 : 윗글에는 없지만 함수 오버로딩도 컴파일러를 속일 수 있다.

직접 확인하기

 

TS Playground - An online editor for exploring TypeScript and JavaScript

The Playground lets you write TypeScript or JavaScript online in a safe and sharable way.

www.typescriptlang.org

function test(a:number):string;
function test(a:string):number;

function test(a: any):string|number{

    return typeof a ==='number' ? Number(a) : String(a);
}
반응형