원문: https://startup-cto.net/10-bad-typescript-habits-to-break-this-year/
타입스크립트와 자바스크립트는 지난 몇 년에 걸쳐 꾸준히 진화했고 우리가 만든 습관 중 일부는 쓸모없어졌다. 어떤 건 애초에 의미가 없었을 수도 있다. 이 글은 꼭 고쳐야 할 나쁜 습관 10가지를 모아봤다.
웹 개발과 기업가 정신에 대해 관심이 있다면 내 트위터 계정을 팔로우하길 바란다.
자 예제로 가보자! "올바른 코드"는 논의된 이슈만 해결했다는 점을 기억하자. 그래서 다른 종류의 코드 스멜이 포함된 상태일 수 있다.
strict
모드를 사용하지 않는다tsconfig.json
에서 strict
모드를 사용하지 않는다.
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs"
}
}
strict
모드를 켠다
{
"compilerOptions": {
"target": "ES2015",
"module": "commonjs",
"strict": true
}
}
기존 코드베이스에 엄격한 룰을 적용하는 것은 시간이 든다.
엄격한 룰은 나중에 코드를 수정하기 쉽게 만들어 주기 때문에 소요됐던 시간들은 추후 코드를 수정할 때 보상받을 수 있고 나중에 저장소에서 작업할 때도 일부의 시간을 보상받는다.
||
을 사용해 정의한다옵셔널 한 값을 ||
을 이용해 보정한다.
function createBlogPost (text: string, author: string, date?: Date) {
return {
text: text,
author: author,
date: date || new Date()
}
}
새로운 연산자인 ??
를 사용한다. 하지만 예제의 경우에는 디폴트 인자를 사용하는 것이 더 나은 방법이라고 할 수 있다.
function createBlogPost (text: string, author: string, date: Date = new Date())
return {
text: text,
author: author,
date: date
}
}
??
연산자는 나온 지 얼마 되지 않았기 때문에 사용할 수 없었고 긴 함수의 중간쯤에서 값이 만들어지는 경우 디폴트 인자로 값을 설정하기 어려웠을 것이다.
??
는 ||
와 달리 모든 falsy
한 값이 아니라 null
과 undefined
값만 보정한다. 그리고 함수가 너무 길어서 시작점에서 디폴트 값을 설정하기 힘들다면 함수를 분리하는 것도 좋은 방법이다.
any
타입을 사용한다사용하는 데이터의 구조를 파악하기 힘들 때 any
타입을 사용한다.
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: any = await response.json()
return products
}
any
타입을 사용해왔던 대부분의 상황에서는 unknown
이 적합하다.
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}
any
는 기본적으로 모든 타입 체크를 무력화시키기 때문에 사용하기 쉬웠다. 심지어 any
는 공식 빌트인 타입에서도 사용된다. (e.g. 위 예제에서 사용된 response.json()
의 리턴 타입은 타입스크립트 팀에 의해 Promise<any>
로 정의되었다.)
any
는 모든 타입 검증을 무력화한다. any
로 오는 모든 것은 어떤 종류의 타입 검증보다도 우선한다. 이건 타입 구조에 대한 가정이 런타임 코드에 의해서만 실패하게 만들기 때문에 버그를 잡기 힘들게 만든다.
val as SomeType
추론이 불가능한 타입에 대하여 컴파일러에게 강제로 알려준다.
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
return products as Product[]
}
바로 이럴때 타입가드를 사용한다.
function isArrayOfProducts (obj: unknown): obj is Product[] {
return Array.isArray(obj) && obj.every(isProduct)
}
function isProduct (obj: unknown): obj is Product {
return obj != null
&& typeof (obj as Product).id === 'string'
}
async function loadProducts(): Promise<Product[]> {
const response = await fetch('https://api.mysite.com/products')
const products: unknown = await response.json()
if (!isArrayOfProducts(products)) {
throw new TypeError('Received malformed products API response')
}
return products
}
자바스크립트에서 타입스크립트로 변환할 때 기존의 코드베이스에서 타입스크립트 컴파일러가 자동으로 추론하지 못했던 타입들을 위해 일부 가정을 만들어야 했다. 이런 경우 as SomeOtherType
을 사용해서 tsconfig
의 설정을 완화할 필요 없이 빠르고 쉽게 변환할 수 있었다.
이런 단언이 현재에는 유용할지 몰라도 누군가 코드를 이동하게 되면 상황은 달라질 것이다. 타입가드를 사용하면 모든 것을 명시적으로 검증했음을 보장할 수 있다.
as any
테스트를 작성할 때 불완전한 대리 타입을 사용한다.
interface User {
id: string
firstName: string
lastName: string
email: string
}
test('createEmailText returns text that greats the user by first name', () => {
const user: User = {
firstName: 'John'
} as any
expect(createEmailText(user)).toContain(user.firstName)
}
테스트에 모의(Mock) 데이터가 필요하다면 모킹 로직을 목을 하는 곳 근처로 이동하여 재사용 가능하게 만든다.
interface User {
id: string
firstName: string
lastName: string
email: string
}
class MockUser implements User {
id = 'id'
firstName = 'John'
lastName = 'Doe'
email = 'john@doe.com'
}
test('createEmailText returns text that greats the user by first name', () => {
const user = new MockUser()
expect(createEmailText(user)).toContain(user.firstName)
}
아직 충분히 테스트 커버리지를 확보하지 않은 코드베이스에서 테스트를 작성하는 경우 종종 복잡하고 거대한 데이터 구조를 다뤄야 하는 상황이 생긴다. 특정한 기능을 테스트할 목적으로 이 중 일부만이 사용된다.
필요 없는 프로퍼티들을 무시하는 것은 단기적으로는 편할 수 있다.
앞서 말했던 모의 데이터의 생성은 결국 독이 되어 우리에게 돌아온다. 나중에 프로퍼티 중 하나가 변경된다면 이 수정을 하나의 중심적인 위치가 아니라 모든 테스트에서 수정해야 한다. 또한 테스트 중인 코드가 이전까지 중요하게 생각하지 않았던 프로퍼티에 의존하는 상황으로 변할 수 있다. 그러면 해당 기능의 모든 테스트들이 업데이트해야 한다.
프로퍼티가 있을 수도 있고 없을 수도 있다면 프로퍼티를 옵셔널로 만든다.
interface Product {
id: string
type: 'digital' | 'physical'
weightInKg?: number
sizeInMb?: number
}
명시적으로 있는 것과 없는 것의 조합으로 만든다.
interface Product {
id: string
type: 'digital' | 'physical'
}
interface DigitalProduct extends Product {
type: 'digital'
sizeInMb: number
}
interface PhysicalProduct extends Product {
type: 'physical'
weightInKg: number
}
프로퍼티를 타입에서 제외하지 않고 옵셔널로 만드는 것이 더 쉽고 코드량이 적다. 하지만 이 방법은 만들게 될 Product
에 대해 깊은 이해를 요구하며 Product
에 대한 변경을 고려한다면 코드의 사용이 제한될 수 있다.
타입 시스템의 가장 큰 이점은 런타임에서 문제가 발생하기 전에 컴파일 타임 검증을 할 수 있다는 점이다. 좀 더 명시적인 타입을 가질수록 발견되지 않을 수 있었던 버그에 대한 컴파일 타임 검사를 받을 수 있다. (e.g. 모든 DigitalProduct
는 sizeInMb
를 갖도록 했다)
제너릭을 한 문자로 네이밍한다.
function head<T> (arr: T[]): T | undefined {
return arr[0]
}
온전히 설명할 수 있는 이름을 부여한다.
function head<Element> (arr: Element[]): Element | undefined {
return arr[0]
}
공식 문서에서조차 한 문자 네이밍이 사용되고 있기 때문에 그 습관이 퍼졌다고 생각한다.
한 문자로 제너릭을 네이밍하는 것은 쉽고 빠르게 입력할 수 있으며 이름 전체를 쓰는 것에 비해 T
누르는 것은 생각이 덜 필요하다.
제너릭 타입 변수도 다른 변수와 마찬가지로 말 그대로 변수다. IDE들이 기술적인 정보(타입)를 보여주기 시작하면서 변수 이름에 기술적인 정보를 포함하는 것은 피하기 시작했다. (e.g. const strName = 'Daniel'
대신 const name = 'Daniel'
이렇게 쓴다) 또한, 한 문자 변수 이름은 변수가 선언된 부분을 보지 않고서는 그 의미를 파악하기 힘들기 때문에 보통 불친절하다.
if
문에 직접 사용함으로써 값이 정의되어 있는지를 확인한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
확인해야 할 조건을 명시적으로 검사한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
검사를 짧게 쓰는 것은 더 간결해 보이며 실제로 검사할 것에 대해 생각하는 것을 피할 수 있게 해준다.
실제로 확인하길 원하는 것이 무엇인지에 대해 생각해야 한다. 위의 예제는 countOfNewMessages
가 0
인 경우를 구별해준다.
불린이 아닌 값을 불린으로 변환한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (!!countOfNewMessages) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
확인해야 할 조건을 명시적으로 검사한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
어떤 사람에겐 !!
를 이해하는 것이 자바스크립트 세계로 입문하기 위한 의식과도 같다. 짧고 간결해 보이며 이미 익숙해져 있다면 바로 무엇인지 알 수 있다. 어떤 값이든 불린으로 바꿀 수 있는 치트키같은 것이다. 특히 코드베이스에 명확하게 의미적으로 falsy 값과 null
, undefined
,''
의 구분이 없는 경우에 말이다.
다른 많은 숏컷과 입문 의식과 같이 !!
의 사용은 내부 지식이 드러나야 할 코드의 진정한 의미를 알기 어렵게 만든다. 일반적으로 개발에 익숙하지 않거나 자바스크립트를 처음 사용하는 새로운 개발자들이 코드베이스를 이해하기 어렵게 만든다. 또한 잠재적인 버그도 만들기 쉽다. "불린이 아닌것의 불린 검사"에서 countOfNewMessages
가 0이 되는 문제는 !!
에서도 되풀이된다.
뱅뱅 연산자의 작은 동생 격인 != null
은 null
과 undefined
를 동시에 검사한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages != null) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
확인해야 할 조건을 명시적으로 검사한다.
function createNewMessagesResponse (countOfNewMessages?: number) {
if (countOfNewMessages !== undefined) {
return `You have ${countOfNewMessages} new messages`
}
return 'Error: Could not retrieve number of new messages'
}
여기까지 왔다면 당신의 코드베이스와 스킬은 꽤 괜찮을 것이라고 생각한다. 대부분의 린팅 룰들이 !=
대신 !==
을 사용하도록 강제하지만 != null
은 예외로 두고 있으며 코드베이스에서 null
과 undefined
에 명확한 구분이 없다면 != null
은 양쪽의 가능성을 확인하는 검사를 짧게 만드는 데 도움이 된다.
자바스크립트 초기에는 null
의 사용이 상당히 번거로운 작업이었지만 타입스크립트의 strict
모드와 함께라면 언어의 가치 있는 도구로 활용될 수 있다. 내가 봐왔던 흔한 패턴은 null
을 값의 부재로 undefined
는 값이 아직 없는 것으로 사용하는 것이다. 예를 들면 user.firstName === null
은 말 그대로 유저가 이름을 갖고 있지 않는 것이고 user.firstName === undefined
은 아직 해당 사용자에게 요청하지 않았음을 의미한다. (그리고 user.firstName === ''
은 말 그대로 이름이 인 것을 의미한다. 실제로 어떤 종류의 이름들이 존재할 수 있는지 알게 된다면 놀랄 것이다)