Inngest에서 이상한 TypeScript 버그가 나왔어. middleware 하나를 붙이면 step.run의 반환 타입이 멀쩡한데, middleware 두 개를 붙이면 반환 타입이 {}로 접혔다. 더 이상한 건 두 middleware가 아무 일도 하지 않는 no-op이어도 같은 문제가 났다는 점이야.1

그러니까 문제는 middleware 안의 코드가 아니었어. “두 번 지나간다”는 구조 자체가 타입을 깨뜨린 거야.

런타임 값이 아니라, 타입 레벨의 JSON 변환이 두 번 적용되면서 무너진 사건이야.

왜 두 번이 문제가 됐나

Inngest의 step.run 결과는 JSON으로 직렬화되어 저장돼. 그래야 재시도할 때 이미 끝난 step을 다시 실행하지 않고 memoization할 수 있어. middleware는 step output을 바꿀 수 있고, 기본 출력 변환은 Jsonify야.1

middleware가 하나면 타입은 Jsonify<T>가 된다. middleware가 두 개면 타입 레벨에서는 Jsonify<Jsonify<T>>가 돼. 런타임에서는 이미 JSON으로 바뀐 값을 한 번 더 JSON으로 바꿔도 사실상 별일이 없지만, TypeScript 타입은 그렇지 않았다.

Inngest 글의 예제는 Widget 타입이었어. 여기에 label?: string이라는 optional property가 있을 때 Jsonify를 두 번 적용하면 media element가 JsonifyObject<{}>로 접혔다. 그런데 label?: string을 지우면 같은 double application이 정상으로 나왔어.1

핵심 단서는 optional property였던 셈이야.

undefined가 key로 새었다

버그의 시작은 흔한 key filtering 패턴이었어. mapped type으로 각 key를 자기 자신이나 never로 바꾼 뒤, 마지막에 [keyof T]로 값을 다시 읽는 방식이지.1

문제는 TypeScript의 mapped type이 optional modifier를 보존한다는 점이야. optional property를 읽으면 그 값의 union에 undefined가 들어간다. 그래서 key union은 "mediaId" | "label"에서 끝나지 않고 "mediaId" | "label" | undefined가 됐다.1

보통 Pick<T, K>K extends keyof T를 요구해. 그런데 generic 타입 안에서는 그 제약 검사가 정의된 자리에서 한 번 승인되고, 나중에 구체 타입으로 펼쳐질 때 다시 검사되지 않았다. 저자는 이 때문에 key set 안에 undefined가 들어간 이상한 object type이 조용히 만들어졌다고 설명해.1

두 번째 Jsonify가 들어오면 그 이상한 타입을 다시 순회한다. 그 과정에서 TypeScript는 T[undefined]를 계산하게 되고, 저자 설명으로는 generic instantiation 안에서 진단을 내는 대신 internal error type을 대입하고 계속 진행한다. 그 error type이 퍼지면서 key filter가 깨지고, 유효한 key가 남지 않은 Pick{}를 만든다.1

결과만 보면 “middleware 두 개가 반환 타입을 빈 객체로 만들었다”야. 안쪽을 보면 optional property 하나에서 나온 undefined가 key union으로 새고, 그 key union이 두 번째 JSON 변환에서 폭발한 거야.

고친 곳은 middleware가 아니었다

처음에는 FilterJsonableKeys 결과를 Exclude<..., undefined>로 감싸는 수정이 나왔다. 이러면 새어 나온 undefined를 잘라낼 수 있어.1

하지만 review 뒤에 더 나은 수정으로 바뀌었다. JsonifyObject에서 key remapping with as를 쓰는 방식이야. 이러면 mapped type 값을 다시 [keyof T]로 읽어 union을 만드는 단계를 없앨 수 있다. undefined를 나중에 잘라내는 게 아니라, 애초에 value-union 단계에서 새지 않게 만든 거지.1

이 글에서 실무적으로 더 재미있는 부분은 테스트야. 저자는 IsEqual<Once, Twice> 같은 alias equality test가 깨진 코드를 통과할 수 있다고 말한다. TypeScript가 deferred alias를 완전히 펼치지 않고 비교할 수 있기 때문이야. 반대로 Twice["media"][number]["mediaId"]처럼 실제 property access를 강제로 시키면 타입 해석이 끝까지 진행되고 버그가 드러난다.1

다음에 볼 것

이 사건은 Inngest만의 특이한 버그로 끝나지 않아. TypeScript에서 “한 번 적용해도 되고 두 번 적용해도 같은 변환”이라고 생각한 타입 유틸리티가 실제로 idempotent한지 확인해야 한다는 얘기야.

특히 JSON 직렬화, API response 정규화, middleware composition처럼 같은 타입 변환이 여러 층에서 겹칠 수 있는 코드에서는 alias끼리 같다고 비교하는 테스트만으로 부족할 수 있어. 실제로 쓰는 property를 찔러보는 assertion이 있어야 해.

그리고 optional property는 가볍게 보면 안 돼. label?: string 하나가 값의 선택성을 뜻하는 데서 끝나지 않고, mapped type을 통과할 때 undefined를 key union 쪽으로 밀어 넣을 수 있다. 이번 버그는 그 작은 차이가 두 번째 변환에서 {}까지 커진 사례야.

각주

  1. Inngest/Linell Bonnette, 「Adding a second middleware broke our typescript types」(2026-07-13) Inngest Blog. ↩︎ ↩︎2 ↩︎3 ↩︎4 ↩︎5 ↩︎6 ↩︎7 ↩︎8 ↩︎9 ↩︎10