satisfies: 안전한 업캐스팅을 통해 더 안전한 코드작성을 도와주는 새로운 키워드(TypeScript 4.9)

5 more properties
TypeScript 4.9 Iteration Plan 이 공개되면서 이제 9월 20일 부터 TypeScript 4.9 Beta 버전을 사용할 수 있게 됩니다. 이번 릴리즈에서 개인적으로 가장 기대하는 기능은 바로 “satisfies” 키워드입니다. 이 글에서는 이 키워드가 무엇이고, 왜 필요하고, 어떤 문제를 해결하는지에 대해서 서술하고자 합니다.
Subeom Choi — 최수범 @0xWOF

satisfies 키워드란? 왜 필요한가?

“satisfies” 키워드는 literal (값) 이나 변수를 안전하게 upcast 하는 기능을 수행합니다. 그런데 이게 어떤 것을 의미할까요?
먼저 아래 예시의 코드를 봐주세요.
const variable = 10
TypeScript
위 코드에서 TypeScript 은 아래와 같이 type 을 추론합니다.
1.
variable 의 type 을 알 수 없다.
2.
10 은 number type 을 가지는 literal 이다.
3.
variable 은 literal 이 assign 되므로 variable 의 type 은 number 이다.
좀 더 복잡한 예제를 다루어봅시다. 아래 코드는 어떻게 추론될까요?
const variable = { grade: "a", score: 90 }
TypeScript
아래와 같습니다.
1.
variable 의 type 을 알 수 없다.
2.
{ grade: "a", score: 90 }{ grade: string, score: number } type 을 가지는 literal 이다.
3.
variable 은 literal 이 assign 되므로 variable 의 type 은 { grade: string, score: number } 이다.
그런데 여기서 문제가 발생합니다. 지금 variable 의 type 은 { grade: string, score: number } 입니다. 만약 variable 을 사용할 때 grade member 에만 접근할 수 있도록 강제하려면 어떻게 하면 될까요?

첫째, variable 의 type 을 미리 정의합니다.

const variable1: { grade: string } = { grade: "a", score: 90 } // error const variable2: { grade: string, score: number, attribute: object } = { grade: "a", score: 90 } const variable3 = { // no way to force type { grade: string } key: { grade: "a", score: 90 } } type Variable4 = { key: { grade: string } } const variable4: Variable4 = { key: { grade: "a", score: 90 } } type Variable5 = { key: { grade: string, score: number, attribute: object } } const variable5: Variable5 = { // error key: { grade: "a", score: 90 } }
TypeScript
이 방법은 변수를 새로 생성하는 경우에는 문제없이 동작합니다. 위 코드에서 “variable1” 케이스를 보시면 안전하게 assign 될 수 있는 경우일 때 에러 없이 type 이 변경되는 것을 보실 수 있습니다. 하지만 “variable2” 케이스를 보시면 안전하게 assign 될 수 없는 경우에는 에러가 발생합니다. 이를 이용해서 저희는 더욱 안전한 코드를 작성할 수 있습니다.
하지만 이 방법은 object 의 key-value 를 정의할 때는 사용할 수 없습니다. “variable3” 케이스를 보시면 해당 key-value 라인에서 type 을 강제할 방법이 없습니다. 물론 “variable4”, “variable5” 케이스와 같이 직접 type 을 새로정의하면 문제를 해결할 수 있지만, type 이 크고 복잡해질수록 이에 대한 관리비용이 증가할 것 입니다.

둘째, “as” 키워드를 사용합니다.

const variable1 = { grade: "a", score: 90 } as { grade: string } // no error (!!!) const variable2 = { grade: "a", score: 90 } as { grade: string, score: number, attribute: object } const variable3 = { key: { grade: "a", score: 90 } as { grade: string } } const variable4 = { key: { grade: "a", score: 90 } as { grade: string } } const variable5 = { // no error (!!!) key: { grade: "a", score: 90 } as { grade: string, score: number, attribute: object } }
TypeScript
이 방법은 object 의 key-value 를 정의할 때에도 사용할 수 있습니다. “variable3”, “variable4” 케이스를 보시면 원하는 type 으로 지정이 가능한 것을 볼 수 있습니다.
하지만 이 방법은 위험합니다. “variable2”, “variable5” 케이스를 보시면 안전하게 type 변환될 수 있는 경우가 아님에도 type 이 변환됩니다. 이는 이후 해당 변수를 사용할 때 버그의 원인이 될 수 있습니다.

"satisfies” 키워드를 사용하면…

const variable1 = { grade: "a", score: 90 } satisfies { grade: string } // error const variable2 = { grade: "a", score: 90 } satisfies { grade: string, score: number, attribute: object } const variable3 = { key: { grade: "a", score: 90 } satisfies { grade: string } } const variable4 = { key: { grade: "a", score: 90 } satisfies { grade: string } } const variable5 = { // error key: { grade: "a", score: 90 } satisfies { grade: string, score: number, attribute: object } }
TypeScript
“satisfies” 키워드는 안전한 type 제한도, object key-value 의 type 제한도 할 수 있습니다. satisfies 는 as 키워드와 같이 expression 에 사용이 가능하기 때문에 object key-value 의 type 을 제한하는 경우에도 사용이 가능합니다. 또한, satisfies 는 as 키워드와 달리 안전한 type 제한을 지원하기 때문에 위험한 “variable2”, “variable5” 케이스에 대해서 컴파일 에러를 발생시켜 개발자가 더욱 안전한 코드를 작성할 수 있도록 돕습니다. 위 방법들을 정리하면 위와 같습니다.
type 정의
as 키워드
satisfies 키워드
안전한 type 제한
object key-value 의 type 제한
“type 정의“ 방법은 안전한 type 제한을 할 수 있지만, object key-value 의 type 제한은 할 수 없고, 하기 위해서는 전체 object 의 type 을 정의해야 합니다. 그리고 as 키워드는 object key-value 의 type 제한은 할 수 있지만, 안전한 type 제한을 할 수 있습니다. 하지만 satisfies 키워드는 2개 모두 만족시킬 수 있습니다.

satisfies 키워드를 사용할 수 있는 사례소개

AB180 Airbridge SDK 팀에서 Web SDK 를 개발하는 경우에 “satisfies” 키워드가 가장 필요한 경우는 Unit Test 를 위한 의존성을 주입하는 경우입니다. 실제로 사용하는 코드를 통해 “satisfies” 키워드를 사용하는 사례를 소개시켜 드리고자 합니다.

국제화를 지원하기 위한 함수의 의존성을 주입하는 사례

const upcast = <Interface> (implementation: Interface): Interface => ( implementation ) const createDependency = () => {} createDependency.internationalize = () => ({ navigator: upcast<{ language?: string, browserLanguage?: string }>( window.navigator, ), }) const internationalize = < Default extends string, Setting extends { default: Default resource: { [key: string]: TextObject } & { [key in Default]: TextObject } }, TextObject = Setting['resource'][Setting['default']], > ( object: Setting, ): TextObject => { const { navigator } = createDependency.internationalize() const language = ( navigator.language ?? navigator.browserLanguage ) const selector = language?.slice(0, 2) if (selector !== undefined && object.resource[selector] !== undefined) { return object.resource[selector]! } else { return object.resource[object.default] } } export { internationalize, createDependency }
TypeScript
위 함수는 Web SDK 에서 국제화관련 지원을 할 때 사용하는 함수입니다. 그리고 위 함수는 아래와 같이 활용하는 것이 가능합니다.
// navigator.language 가 ko 으로 시작할 때 // => { hello: '안녕하세요.' } // navigator.language 가 en 으로 시작할 때, 그리고 다른 경우 // => { hello: 'hello.' } // type: { hello: string } const text = internationalize({ default: 'en', resource: { en: { hello: 'hello.', }, ko: { hello: '안녕하세요.', }, }, })
TypeScript
위 함수에서 아래 부분을 주목해주시면, window.navigator 를 upcast 라는 함수로 감싼 것을 확인하실 수 있습니다. upcast 함수는 위의 “type 정의” 방법을 함수를 통해 사용해서 object key-value 의 type 제한에도 활용할 수 있도록 합니다. 이를 통해 createDependency.internationalize 함수가 반환하는 object 의 type 은 { navigator: Navigator } 가 아닌, { navigator: { language?: string, browserLanguage?: string } } 가 됩니다.
const upcast = <Interface> (implementation: Interface): Interface => { return implementation } const createDependency = () => {} createDependency.internationalize = () => ({ navigator: upcast<{ language?: string, browserLanguage?: string }>( window.navigator, ), })
TypeScript

upcast 함수가 필요한 이유

이런 type 제한이 왜 필요할까요? 그냥 createDependency.internationalize 함수가 반환하는 object 의 type 이 { navigator: Navigator } 가 되면 안될까요? 그 이유는 Unit Test 에서 찾을 수 있습니다. internationalize 함수는 navigator 를 사용하는 부분을 제외한 모든 부분이 순수합니다. 즉 저는 navigator 만 주입할 수 있다면 internationalize 함수를 ECMAScript 를 지원하는 모든 환경에서 테스트하는 것이 가능합니다. 하지만 Navigator 타입은 매우 많은 member, method 를 지원합니다. 그러므로 이에 대한 mock 을 만드는것은 상당히 힘든 작업입니다.
대신, upcast 함수를 이용해 type 을 { navigator: { language?: string, browserLanguage?: string } } 으로 제한해서 필요한 member 만 사용한다면, mock 을 만들기 매우 쉬워집니다. { language: 'ko' } 이것또한 훌륭한 mock 이 됩니다.
import { internationalize, createDependency } from '...' test('internationalize - ko', () => { createDependency.internationalize = () => ({ navigator: { language: 'ko' } }) const text = internationalize({ default: 'en', resource: { en: { hello: 'hello.', }, ko: { hello: '안녕하세요.', }, }, }) expect(text.hello).toBe('안녕하세요.') })
TypeScript

“satisfies” 키워드 사용

const createDependency = () => {} createDependency.internationalize = () => ({ navigator: window.navigator satisfies { language?: string, browserLanguage?: string } })
TypeScript
“satisfies” 키워드를 사용하면 위와 같이 upcast 함수 없이도 목적을 달성할 수 있습니다. 또 upcast 함수가 대부분의 minifier 에서 최적화되겠지만, 그렇지 못한 minifier 도 있을 수 있습니다. (설정에 따라 최적화가 안될 수도 있구요.) 대신 “satisfies” 키워드를 사용한다면 TypeScript 가 지원되는 모든 환경에서 최적화된 결과물을 얻을 수 있습니다.

결론

TypeScript 4.9 에서 추가되는 “satisfies” 키워드는 언제 어디서나 안전한 upcast 를 하는데 도움을 줍니다. 이는 코드를 작성하는데 있어 더 안전한 코드를 작성하는데 활용할 수 있으며, 기존에 이를 달성하기 위해 사용하던 도구함수를 제거할 수 있게 해줍니다. 이를 활용해 더 좋은 코드를 작성할 수 있게 될 것이라고 기대합니다.
ᴡʀɪᴛᴇʀ
Subeom Choi @0xWOF SDK Team Lead @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기