ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 2. express 타입 이해...2
    분석과탐구 2023. 1. 7. 20:43

    express 예제의 타입 확인.

    이번 글의 목표는 npm에 올라온 express 예제의 타입을 확인하는 것이다. 다음은 express 예제에 번호를 추가한 것이다.

    const express = require('express') // (1)
    const app = express() // (2)
    
    app.get('/', function (req, res) { // (3)
      res.send('Hello World') // (4)
    })
    
    app.listen(3000) // (5)

    이 예제에 타입이 추가된 코드는 다음과 같다. (1)부터 (5)까지 코드를 보며 타입을 알아볼 것이다.

    (1) ~ (5)번으로 넘버링된 줄을 하나씩 살펴보자.

     

    (1) : "export = "로 모듈을 내보내는 파일을 받을 때 쓰는 방식이다.

     

    (2) : (1)에서 import한 express는 리턴 타입이 core.Express인 함수이다. 그런데, app의 타입은 express.Express이다. express의 리턴 타입대로라면, express.core.Express이어야 할 것이다. 하지만, 다르다. 확인이 필요하다. Express의 타입은 다음과 같다.

    index.d.ts를 보면, Express가 core.Express를 상속한다.

    확인을 해보니, Expree는 core.Exprees를 확장하는 인터페이스이므로 Expres는 core.Express를 대체할 수 있다. 리스코프 치환 원칙을 떠올리면 된다.

     

    (3) : get 메소드이다. 첫 번째 인자로 경로(string)를 받고, 두 번째 인자로 콜백함수를 받는 함수이다. get의 정확한 타입은 무엇인가? 타입을 확인해 보자.

    express-serve-static-core/index.d.ts파일

    get 메소드는 두 함수의 타입 인터섹션(type intersection)이다. 첫 번째 함수는 string 타입의 파라미터 하나를 가지며, 리턴 타입이 any이다. 두 번째 함수는 "IRouterMatcher<this>"라는 타입을 가진 함수이다. 타입을 더 자세하게 보기 위하여 타입 인터섹션과 this 타입을 살펴보고 넘어가야 한다.

    타입 인터섹션(type intersection)

    타입 인터섹션(type intersection)은 타입 유니온(type union)과 함께 기존에 있는 타입을 이용하여 새로운 타입을 만들기 위한 기법이다. 타입스크립트 핸드북에선 다음과 같이 설명한다.

    https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#intersection-types

    타입스크립트 핸드북을 보면, 타입 인터섹션은 여러 개의 타입 조건들을 모두 만족하는 새로운 타입이다. 몇가지 상황을 통해서 구체적으로 살펴보자.

    1) primitive 타입으로 타입 인터섹션을 하는 경우

    https://stackoverflow.com/questions/64614085/intersection-types-with-typescript

    primitive한 타입을 모두 만족하는 새로운 타입이 나와야 한다. 만약 나오지 않는다면, never 타입이 된다.  

    B의 타입은 number literal 1이고 A는 number라 호환된다.

    number와 number literal 1을 모두 만족하는 타입은 number literal 1이다.

     

    2) primitive가 아닌 타입의 경우. 이 경우에도 인터섹션한 타입 모두를 만족하는 새로운 타입이 나와야 한다.

    interface A와 B를 합치면 새로운 인터페이스가 나타난다. type도 마찬가지.

     

    위 예제를 이해하기 위하여 객체 타입의 수퍼 타입(super type)과 서브 타입(sub type)을 이해할 필요성이 있다. 수퍼 타입과 서브 타입 이전에, 우리가 클래스의 상속관계를 나타낼 때, 수퍼 클래스와 서브 클래스를 이야기하곤 한다. 수퍼 클래스는 서브 클래스를 포함하는 개념이지만, 동시에 수퍼 클래스에 없는 요소들이 서브 클래스에 있기도 하다. 두 관계의 특징 중에 살펴볼 것은 두 가지이다.

     

    첫 번째, 수퍼 클래스는 서브 클래스를 포함한다.

    두 번째, 리스코프 치환원칙(수퍼 클래스를 서브 클래스로 대체할 때 제대로 동작해야 한다.)

     

    위 특징을 바탕으로, 인터페이스 A의 수퍼 타입은 뭘까? 그것은 바로 인터페이스 A 자신과 {} 빈 객체 타입이다. 그렇다면, 인터페이스 A의 서브 타입은 뭘까? 그것은 바로 인터페이스 A 자신과 인터페이스 A에 추가로 어떤 것이 포함된 상태이다. 도식으로 보면 다음과 같다.

    인터페이스 A의 수퍼 타입과 서브 타입

    따라서, 인터페이스 A와 인터페이스 B의 인터섹트 결과는 다음과 같게 된다. 

    인터페이스 AB는 A와 B를 모두 포함한다. 동시에 A의 서브 타입이면서 B의 서브 타입이다. 또한, AB는 A에 대입가능하고 B에도 대입 가능하다. 이 관계를 코드로 보자.

    {} >= {name: string} >= {name: string, age: number}

    리스코프 치환 원칙을 실험해보면 제대로 동작한다. c에 a나 b를 대립하려고 하면 컴파일 에러가 발생한다.

     

    3) 프로퍼티 이름이 같은 경우. 이 경우에도 역시 인터섹션한 타입 모두를 만족하는 새로운 타입이 나와야 한다.

    타입 never로 판정

    인터페이스 A와 B의 address 프로퍼티의 타입은 각각 number와 boolean이다. number와 boolean을 모두 만족하는 타입이 있을까? 없다. 그래서 타입이 never가 된다. 다음 예시는 string과 string literal "11"을 모두 만족하는 string literal "11" 타입으로 되는 것을 보여준다.

    타입 "11"인 string literal로 판정

    4) 인터페이스의 프로퍼티 이름은 같은데 그 타입이 함수인 경우.

    여태까지 본 규칙에 의거하면 address의 파라미터 number와 string을 모두 만족하는 타입은 없으므로 타입이 never가 될 것으로 예측할 수 있다. 하지만, 새로운 타입은 nuber | string이다. 이 결과를 정확히 이해하기 위해서 공변성과 반공변성 개념을 도입해야 한다. 공변성은 A가 B의 서브 타입일 때, T<A>도 T<B>의 서브 타입을 가리킨다. 반공변성은 A가 B의 서브 타입일 때, T<B>가 T<A>의 서브타입이 된다. 여기서 T는 A를 이용한 어떤 제네릭한 클래스, 함수를 떠올리면 된다. 위 예제에선 함수가 될 것이다.

     

    타입스크립트에서는 함수 파라미터의 타입에 대해서는 반공변성을 적용하고 리턴 타입에 대해선 공변성을 적용한다. 따라서, number는 number | string의 서브타입이지만, 반대로 함수에 쓰이는 인자 number | string은 number의 서브타입이 되어 위 코드처럼 대입할 수 있는 것이다.

    위 코드를 보면 리턴 타입이 다른 것을 알 수 있다. 위 경우엔 함수의 인자에 대하여 반공변성을 적용되었고, 리턴 타입은 공변성이 적용되어 never가 된다. 그리고, never를 포함하는 any도 될 수 있다.

    this 타입과 제네릭 인터페이스 함수

    타입 인터섹션 이전에 원래 확인해보고 싶었던 것은 express의 get 메소드의 타입이었다. get 메소드 코드를 다시 보자.

    원하는 것은 (3)과 (4)의 타입을 확인해보는 것이었다.
    get 메소드의 타입이다.

    위 코드를 이해하기 위해선 함수의 타입 인터섹션과 this 타입의 이해가 필요했다. 타입 인터섹션으 봤으니, this 타입을 보자.

     

    타입스크립트의 제네릭에서 재밌는 점은 any 타입이 있는 점이다. 클래스, 함수, 변수 등의 대상에게 타입을 any로 하면 그 자체로 제네릭하다. 그러나, any 타입의 문제점은 구체적인 타입이 any에 녹아서 사라진다는 것이다. any를 쓰면, 구체적인 타입을 살릴 방법이 없는 것이다. 그래서, 타입스크립트 제네릭은 타입을 구체화하는 것이 특징이다. 타입스크립트 핸드북의 예제는 다음과 같다.

    https://www.typescriptlang.org/docs/handbook/2/generics.html

    제네릭을 나타낼 때 다른 언어에서도 쓰던 "<타입>" 표기법을 쓴다.

    오브젝트 리터럴을 이용하여 감싸줘도 가능하다.
    인터페이스를 제네릭 함수로 사용할수도 있다.

    위 예제가 낯설다. 인터페이스를 제네릭한 함수처럼 사용한다. 실제 사용해 보면 다음과 같다.

    위 예제를 실제 실행해보면 잘 된다. fn 코드 자체는 저렇게 하면 안되지만, 테스트로 돌려보았다.

    이 코드를 잘 확장해보면, IRouterMatch 인터페이스와 비슷해진다.

    제네릭 함수를 정의한 인터페이스. 함수 시그내쳐가 여러개다.

    이 코드에서 Type을 다양하게 넣고 인자를 확대한 결과가 IRouterMatch이다. 인터페이스의 제네릭함수는 여러 시그내쳐를 가질 수 있다. 타입스크립트 핸드북을 보자.

    https://stackoverflow.com/questions/51766690/typescript-interface-with-generic-multiple-call-signatures

    인터페이스에 제네릭 함수를 정의할 수 있고, 여러 개의 시그내쳐를 가질 수 있지만, 오로지 하나의 구현만을 가진다고 되어 있다. 이 설명을 바탕으로 한 코드를 보자.

    컴파일 에러가 없다.

     중요한 것은 두 시그내쳐가 호환가능하도록 하는 것이다. 그러니까... 만약에 arg2에 있는 옵셔널 파라미터를 제외한다면 컴파일 에러가 발생한다.

    옵셔널 파라미터를 제거하면, 인자가 1개 시그내쳐를 가진 함수를 제거해도, 포함해도 에러가 생긴다. 호환이 안되니까.
    시그내쳐 2개인 함수를 없애고, 1개인 함수에 바디를 달아주면 컴파일이 통과된다.

    따라서, 두 시그내쳐에 호환되는 구현을 해야 하는 것이다. 필요한 만큼 제네럭 인터페이스는 본 거 같고... 남은 것은 this타입이다. 타입스크립트 핸드북을 보자.

    https://www.typescriptlang.org/docs/handbook/advanced-types.html#polymorphic-this-types

    this타입은 클래스나 인터페이스의 서브 타입이 될 수 있는 타입이다. 어떤 인터페이스 A가 있고, 인터페이스 A에 대한 this 타입은 A의 서브타입, 즉 A 자신 혹은 A를 extends한 타입이 될 수 있다는 것이다. 메소드의 리턴 타입을 해당 클래스나 인터페이스의 서브 타입으로 지정하는 기법인 fluent api가 대표적인 사용법이다. 예제를 보면 다음과 같다.

    다시 get메소드로

    get 메소드의 타입이다.

    get 메소드는 두 함수의 타입 인터섹션(type intersection)이다. 첫 번째 함수는 string 타입의 파라미터 하나를 가지며, 리턴 타입이 any이다. 두 번째 함수는 "IRouterMatcher"라는 타입을 가진 함수이다.  우리는 이것을 이해하기 위하여 타입 인터섹션과 제네릭 인터페이스 함수 그리고 this 타입을 살펴보고 넘어왔다.

     

    위 코드의 IRouterMatcher<this>에서 this는 어떤 것을 가리키는가?

    여기서의 this는 get 메소드가 속한 인터페이스 Application을 가리킨다.

    this가 가리키는 것은 제네릭 인터페이스 Application이다.

    그러므로, IRouterMatcher<this>는 IRouterMatcher<Application의 서브타입>으로 봐도 괜찮을 것이다.

    IRouterMatcher의 타입은 다음과 같다.

    타입 유니온과 타입 constraint를 이용하여 Method의 타입을 'all' ...'head'까지 좁히고 있는 것을 알 수 있다. 그러면 = any는 무슨 의미인가?

    IRouterMatcher는 제네릭 인터페이스로 T, Method 두 가지 타입을 받는다. T는 별다른 조건이 없다. Method는 스트링 리터럴의 타입들의 유니온으로 타입을 제한하고 있다. 그리고, 자세히 보면 Method의 마지막에 "= any"가 있다. 이것은 제네릭 파라미터 디폴트를 의미한다. Method 자리에 아무것도 안 넣으면 any로 간주하고 진행하는 것이다. 하지만, Method에 어떤 타입을 넣었을 때, 그 타입이 'all' ... 'head'와 호환되지 않는 것이 하나라도 있다면, 통과되지 않는다.

     

    IRouterMatcher의 코드를 계속해서 보자.

    IRouterMatcher는 인터페이스를 통해서 구현한 제네릭 함수의 일종이다. 함수의 시그내쳐에서 앞에 있는 제네릭을 제외하고 보면, path와 ...handlers라는 두 파라미터가 남는다. path의 타입은 Route로 이것은 제네릭으로 지정되어 있는데, string 타입으로 제한되는 타입이다.

    handlers는 레스트 파라미터를 사용하였다. 인자들이 몇 개인지 미리 알 수는 없지만, 받은 인자들을 handlers라는 배열로 묶는 것이다. 인자로 들어온 함수를 Array<RequestHandler>라는 배열로 묶어서 get 메소드 내부에 전달하는 것이다. 따라서 인자로 들어올 수 있는 타입은 RequestHandler이다. RequestHandler는 제네릭 타입으로 <P, ... , Locals>라는 타입의 제네릭을 사용한다. RequestHandler를 살펴보자. 코드는 다음과 같다.

    RequestHandler는 req, res, next를 받는 제네릭 인터페이스 함수이다.

    RequestHandler는 제네릭 인터페이스 함수이다. 3개의 인자 req, res, next를 가지고 리턴 타입은 void인 함수인 것이다. 따라서 앞서 살펴본 IRouterMatcher 제네릭 인터페이스 함수는 첫 번째 인자로 string으로 제한된 인자를 받고, 두 번째 인자로 콜백 함수를 받는 것이다. get 메소드가 사용된 예제를 다시 보자.

    첫 번째 인자는 string, 두 번째 인자는 함수이다.

    우리가 앞에서 봤던 get 메소드의 시그내쳐는 다음과 같다.

    get 메소드의 시그내쳐

    이 코드의 타입을 전개해 보면 다음과 같다.

     

    IRouterMatcher<this>

    IRouterMatcher<Application, any>

    (path: Route, ...handlers: Array<RequestHandler<여러 타입들>>): Application

     

    따라서 get 메소드를 구성하는 타입 인터섹션은 다음 두 함수의 타입 인터섹션이 된다.

     

    ((name: string) => any) & ((path: Route, ...handlers: Array<requesthandler<..>>): Application)

     

    타입 인터섹션은 인터섹션에 사용된 타입들을 모두 만족하는 결과를 내놓는다. 그렇다면, 두 함수의 인터섹션을 만족하는 함수는 무엇일까? 첫 번째 인자는 반드시 들어가야 하고, 이 둘을 모두 만족하는 결과를 내야하니 path: Route가 될 것이다. Route는 string literal의 유니온이라 string보다 좁기 때문이다. 두 번째 인자는 어떻게 될까? 첫 번째 함수에는 두 번째 인자가 없고, 두 번째 함수에는 있다. 이 경우에 인터섹션으로 만족하는 결과는 무엇인가? 테스트를 해본다.

    중복되는 첫 번째 인자는 그대로 들어가지만, 두 번째 인자인 콜백함수는 옵셔널 파라미터로 들어가게 된다. 이 결과를 바탕으로 get 메소드는 다음과 같은 타입을 지닐 것이라고 추측할 수 있다.

     

    ((name: string) => any) & ((path: Route, ): Application)</requesthandler<..>

    위 타입은 다음과 같이 추론될 것이다.

    (path: Route, ...handlers?: Array<requesthandler<..>></requesthandler<..>): Application

     

    express의 get 메소드 사용 코드를 보자.

    get 메소드에서 첫 번째 파라미터 뒤의 콜백 함수를 생략할 수 있다.

    설명이 길어짐에 따라 원래 목표가 흐릿해진 감이 있다. 우리의 목표는 익스프레스 예제의 타입을 확인해 보는 것이었다. 예제의 각 줄에 넘버링을 했었고, 여기까지가 (1) ~ (3)에 해당한다. 나머지 (4), (5)는 (1) ~ (3)을 바탕으로 간단하게 확인할 수 있다.

    넘버링 4번 코드

    (4) 코드는 res 객체의 send 메소드를 호출하고 있다. Response 인터페이스의 타입은 다음과 같다.

    res 객체의 타입은 Response<ResBody, Locals, StatusCode>이다. 세 개의 제네릭 타입을 받고 있다. 모두 제네릭 디폴트 타입이 지정되어 있는 것을 확인할 수 있다. 그리고 http.ServerResponse와 Express.Response를 확장하는 형태이다. send 메소드는 다음과 같다.

    Send라는 타입에 제네릭으로 ResBody와, this 타입을 넘기고 있다.

    send 메소드가 타입으로 삼은 Send 타입은 다음과 같다.

    Send는 body가 옵셔널 파라미터이고 타입이 ResBody이며 리턴타입은 T인 함수이다.

    Send타입은 함수 타입이다. 인자로 넘겨주는 body가 옵셔널 파라미터이고 리턴타입은 T다.

    위 코드에서 ResBody의 type을 string이라고 한다면, Send의 타입은 다음과 같다.

    Send<string, Response<string>> = (body? string) => Response<string>;

    Send의 타입을 response 객체의 send메소드로 확장해보면 다음과 같다.

    send: Send<ResBody, this>;

    send: Send<string, this>

    send: (body? string) => this;

    this타입을 리턴으로 하므로 fluent api 형태로 기술이 가능하다.(res.send().send()...)

     

    (5)의 listen 코드는 다음과 같다.

    listen의 타입은 다음과 같다. 다양하게 오버로딩이 되어 있는 것을 볼 수 있다.

    마무리

    npm 패키지의 express 예제에 적용된 타입을 살펴보았다. 타입을 분석하기 위해서 타입 인터섹션(type intersection), 함수의 타입 인터섹션, 옵셔널 파라미터가 되는 인자, 공변성과 반공변성, 제네릭 인터페이스 함수, 제네릭 디폴트 파라미터, this 타입 등의 원리를 알아보았다.

    참고

    https://www.typescriptlang.org/docs/handbook/unions-and-intersections.html#intersection-types

    https://stackoverflow.com/questions/64614085/intersection-types-with-typescript

    https://www.typescriptlang.org/docs/handbook/2/generics.html

    https://stackoverflow.com/questions/51766690/typescript-interface-with-generic-multiple-call-signatures

    '분석과탐구' 카테고리의 다른 글

    CRA의 npm run start는 서버를 띄우는 걸까?  (0) 2023.03.28
    express의 Request 타입 확장하기  (0) 2023.03.13
    1. express 타입 이해...1  (0) 2023.01.07
    std::move에 대하여  (0) 2022.10.03
    CRTP는 어떻게 가능할까?  (0) 2021.10.13

    댓글

Designed by Tistory.