Zustand Type Inferences?
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에서 직접 ...State
을 create
의 인자로 넣어주어야 하는 이유가 그것인데, create
의 인자로 전달하는 함수에서 반환하는 값으로 State
의 형태를 추측하기가 불가능하기 때문이다.
이는 비롯 TypeScript의 문제만은 아니며 프로그래밍 언어에서 Generic이라는 개념이 정립된 이레로 해결될 수 없는 문제라고 보는게 맞을 것이다.
이 문제는 Circular Dependencies문제처럼 각자의 타입들이 자신의 타입을 정의하기 위한 의존성이 순환참조를 이루어 결국 타입이 정의될 수 없게 된다.
Docs의 예시를 보면 다음과 같다.
const createFoo = {} as <T>(f: (t: T) => T) => T;
const x = createFoo((_) => 'hello');
x
는 createFoo
에 전달된 함수가 string
을 반환하므로 string
이 되어야 할 것 같지만, 실제로는 추측될 수 없어 unknown
이 된다.
이유는 당연히 TypeScript가 T
를 추론할 수 없게 되기 때문이다. f
의 return type으로 T
는 hello
라는 문자열임이 보이지만, 이것이 타입인자 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이다.
Comments