image.png

Generic과 Invariance, Covariance, Contravariance에 대한 이해는 예전에 썼던 코틀린 제네릭 in-out란 글에서 절충할 수 있다. 코틀린을 몰라도 쉽게 읽을 수 있도록 작성된 글이다.

React의 유명한 상태관리 라이브러리인 Zustand의 TypeScript에서 동작하는 유용한 Slice 자동 생성기를 만드려 TypeScript Generic과 씨름하고 있던 도중, Zustand TypeScript문서에서 흥미로운 파트를 읽었다.

접힌 부분들을 펼쳐보다보면 Zustand의 create가 대략 어떻게 TypeScript에서 동작하게 만들어졌는지, 그리고 그걸 구현할 때 두 가지 문제점이 있었음을 알 수 있다.

첫 번째 문제 - Inferences Failure

Wait a minute, if it’s impossible to implement create then how does Zustand implement it? The answer is, it doesn’t. - Zustand Documentation

첫 번째는 Inferences Failure인데, 이는 강제적인 타입을 지정함으로써 해결할 수 있다.

TypeScript에서 직접 ...Statecreate의 인자로 넣어주어야 하는 이유가 그것인데, create의 인자로 전달하는 함수에서 반환하는 값으로 State의 형태를 추측하기가 불가능하기 때문이다.

이는 비롯 TypeScript의 문제만은 아니며 프로그래밍 언어에서 Generic이라는 개념이 정립된 이레로 해결될 수 없는 문제라고 보는게 맞을 것이다.

이 문제는 Circular Dependencies문제처럼 각자의 타입들이 자신의 타입을 정의하기 위한 의존성이 순환참조를 이루어 결국 타입이 정의될 수 없게 된다.

Docs의 예시를 보면 다음과 같다.

const createFoo = {} as <T>(f: (t: T) => T) => T;  
const x = createFoo((_) => 'hello');

xcreateFoo에 전달된 함수가 string을 반환하므로 string이 되어야 할 것 같지만, 실제로는 추측될 수 없어 unknown이 된다.

이유는 당연히 TypeScript가 T를 추론할 수 없게 되기 때문이다. f의 return type으로 Thello라는 문자열임이 보이지만, 이것이 타입인자 T로써 반환되는 것은 그것이 covariant(producer)임을 의미한다. 이는 읽기 전용으로 타입이 반환된다는 것인데, 그와 동시에 f가 인자로 T를 받음으로써 T는 동시에 contravariant(consumer)가 되버린다. 그리고 이는 결국 invariant(불변)가 된다.

T가 invariant가 될 때 왜 unknown으로 변환되는지는 잘 모르겠지만 이것은 TypeScript의 제약인 것 같기도 하고 사실 잘 이해가 안간다.

어쨋든 이것이 Zustand가 create를 구현할 때 가지고 있던 첫 번째 문제이다.

두 번째 문제 - Soundness Failure

첫 번째 문제는 타입을 직접 개발자가 지정해줌으로써 순환 참조의 고리를 끊어 해결했다면, 두 번째는 사실상 해결되지 않은 문제이고, 큰 문제가 되지 않아 방치중이라고 한다.

현재 Zustand에서 다음과 같이 코드를 짜면 get()undefined가 되어 오류가 난다. 실제로 나도 이 것을 경험해보고 이슈를 찾아보았다.

import { create } from 'zustand'

const useBoundStore = create<{ foo: number }>()((_, get) => ({
  foo: get().foo,
}))

원인은 get이 반환하는 상태가 처음에는 초기화되지 않은 상태이기 때문이고, Zustand 팀은 get의 타입을 () => T | undefined 으로 둘 수도 있었지만 그것이 사용을 불편하게 하고 초기에 get을 사용하는 일만 없으면 되기 때문에 () => T로 그냥 두었다고 한다.

나중에 TypeScript에 기능이 추가되어 해결되지 않는 한 이대로 두어질 것이다.

Curry를 사용해야 하는 이유

사실 이걸 알아보려다 Zustand TypeScript Docs까지 다시 온건데, TypeScript는 가끔 Generic 코드를 짜다보면 이게 왜 이딴식으로 되지라는 느낌이 들 때가 있는데 그것은 TypeScript Issue때문이다.

Generic인자를 하나라도 전달하게 되면 나머지값들은 더 이상 추론되지 않고 직접 모두 입력해줘야 하는 동작으로 변경되게된다.

Currying은 이 이슈의 여러 해결책 중 Zustand가 채택한 해결책이다.

단, 위치에 상관없는 건 아니고 Curry형태의 함수가 앞의 인자들 고정된 N개를 입력받을 수 있고 나머지는 추론되게 하고 싶을 때 가능한 해결책이다.

declare const withError: {
  <E>(): <T>(
    p: Promise<T>,
  ) => Promise<[error: undefined, value: T] | [error: E, value: undefined]>
  <T, E>(
    p: Promise<T>,
  ): Promise<[error: undefined, value: T] | [error: E, value: undefined]>
}
declare const doSomething: () => Promise<string>
interface Foo {
  bar: string
}

const main = async () => {
  let [error, value] = await withError<Foo>()(doSomething())
}

위 예시는 withError는 curry 함수가 E를 받고 반환하는 함수가 T를 가짐에 따라 E까지는 개발자가 직접 입력할 수 있게도 하고 T부터는 반환되는 함수에서 추론되게 하는 Workaround이다.

Categories:

Updated:

Comments