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 을 만드는것은 상당히 힘든 작업입니다.
Navigator 타입에 대해서: https://developer.mozilla.org/ko/docs/Web/API/Navigator
대신, 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