Generic이란 용어는 type을 엄격하게 정해서 사용하는 Java 등의 언어에서는 매우 친숙한 용어이지만,

type이란 것을 전혀 고려하지 않고 사용하는 JavaScript에서는 매우 생소한 용어입니다. 🤷‍♂️

const array = [];

// 뭐든 다 들어와~~!
array.push("string");
array.push(12);
array.push({});
array.push([]);

위 처럼 array에 모든 type의 값이 허용됩니다! 변수도 마찬가지이죠!

그렇다면 이번엔 type을 지정하고 동일한 기능을 하는 Generic을 적용해 보겠습니다.

const array: string[] = [];
array.push("any string!!!");
// array.push(12); X type error

// Generic
const arr: Array<string> = [];
arr.push("any string!!!");
// arr.push(12); X type error

위 배열 안에는 string만 들어갈 수 있습니다!

이렇게 직접 type 설정으로도 배열의 내용물 등을 정할 수 있는데 왜 굳이 Generic이 필요할 까요????

Generic사용의 이유

Generic은 정적타입의 언어에서 좀 더 범용성있는 함수와 객체를 활용할 수 있도록 만들어 졌습니다.

정적타입 언어는 지정한 타입만 받아 들일 수가 있어, 만약 여러 타입을 받아들이려면 함수를 overload하거나 객체를 상속하는 등의 작업이 필요합니다.

위와 같은 번거로운 작업을 Generic이라는 것으로 한방에 해결할 수 있습니다.

밑에서 자세한 내용을 살펴보겠습니다.

함수에서의 Generic 활용

우선 type지정으로 merge라는 함수를 만들어 보겠습니다.

// 두 object의 property를 합치는 합수입니다.
function merge(objA: object, objB: object): object {
	return Object.assign(objA, objB);
}

const mergeObj = merge({ name: "Jun" }, { age: 20 });
mergeObj.age; // X type error

merge를 사용해 두 object의 property를 합쳤으니 age라는 property가 존재해야하지만 type error가 발생했습니다…!

그 이유는 merge는 object를 return하긴 하지만 그 object가 어떤 property가 있는지 정확히 알 수 없습니다.

그렇다면 이 type error를 해결해보겠습니다…!

// 방법 1: return하는 object의 property를 직접 지정
function merge(objA: object, objB: object): { name: string, age: number } {
	return Object.assign(objA, objB);
}

// 방법 2: type casting을 통해 obejct의 property를 확실히 해준다...!
const mergeObj = merge({ name: "Jun" }, { age: 20 }) as { name: string, age: number };

위의 두 방법으로 해결 할 수 있지만… 저 type들을 직접 써주자니…

  • 구체적인 type 제한으로 함수의 범용성이 크게 줄어들고
  • 넣어주는 데이터마다 type을 지정하거나 바꿔줘야하니 너무 번거롭습니다…

이것을 Generic을 통해 해결해보겠습니다.

임의로 정해지는 Generic

// 두 object의 property를 합치는 합수입니다.
function merge<T, U>(objA: T, objB: U): T & U {
	return Object.assign(objA, objB);
}

const mergeObj = merge({ name: "Jun" }, { age: 20 });
mergeObj.age; // O
mergeObj.name; // O
const mergeObj2 = merge({ name: "Jun", hobby: "drum" }, { age: 20 });
mergeObj2.hobby; // O

<T, U>는 parameter에 들어온 type에 따라 결정되는 “Generic” type입니다.

따라서 처음에 { name: “Jun” }는 “T” 타입이 되고 { age: 20 }는 “U” 타입이 되어 return시에는 T와 U가 합쳐진 Object가 반환되어 return된 값의 property를 부르면 type error가 전혀 나지 않게 됩니다…!!

merge 함수의 확장성이 크게 좋아졌습니다…!

하지만 이렇게만 둔다면 object이외의 type들도 parameter로 들어올 수 있게되서 런타임 에러를 일으키겠죠?!

지정하는 Generic

따라서 T와 U를 좀 더 명확히 해보도록 하겠습니다.

function merge<T, U>(objA: T, objB: U): T & U {
	return Object.assign(objA, objB);
}

// T는 { name: string }, U는 { age: number }
const mergeObj = merge<{ name: string }, { age: number }>({ name: "Jun" }, { age: 20 });
mergeObj.age; // O
mergeObj.name; // O

위와 같이 실행시 Generic type을 정해주는 것을 “constraint”라고 합니다.

하지만 위처럼 해주는 것은 직접 type을 써주는 번거로운 작업과 다를 바 없죠

따라서,,, 아래와 같이 보강해보겠습니다.

// object를 상속했기 때문에 object 관련한 것만 T, U가 될 수 있다...!
function merge<T extends object, U extends object>(objA: T, objB: U): T & U {
	return Object.assign(objA, objB);
}

// T는 { name: string }, U는 { age: number }
const mergeObj = merge({ name: "Jun" }, { age: 20 });
mergeObj.age; // O
mergeObj.name; // O

// const mergeAnyThing = merge("string", 12); X type error

T와 U를 object에 상속시켜 T와 U가 object 관련 type이 아니면 지정이 될 수 없도록 제한을 두었습니다.

드디어 진정한 object만 받을 수 있는 함수가 된 것 같네요…!

extends 응용

그렇다면 위의 extends를 응용해 보겠습니다.

이번에 만들어보는 함수의 parameter는 object 뿐만 아니라 length property를 가진 모든 element가 그 대상입니다.

function countAndDescribe<T>(element: T) {
	let descriptionText = "Got no value!";

	if (element.length === 1) {  // X type error
		descriptionText = "Got 1 value!";
	} else if (element.length > 1) {  // X type error
		descriptionText = `Got ${element.length} values!`; // X type error
	}

	return [element, descriptionText];
}

우선 위에서 element가 length property를 가지고 있을지 모르기 때문에 type error가 납니다. 따라서

interface Lengthy {
	length: number;
}

function countAndDescribe<T extends Lengthy>(element: T): [T, string] {
	let descriptionText = "Got no value!";

	if (element.length === 1) {  // O
		descriptionText = "Got 1 value!";
	} else if (element.length > 1) {  // O
		descriptionText = `Got ${element.length} values!`; // O
	}

	return [element, descriptionText];
}

countAndDescribe("HI!"); // O
countAndDescribe(["HI!", "hello!"]); // O
// countAndDescribe(9); X type error

이런 식으로 직접 만든 interface로 type이 number인 length property를 가진 object면 뭐든 가능하도록 설정할 수 있습니다.

keyof constraint

가끔 이런 경우도 있습니다.

object를 받고 string을 key로 받아 object에서 해당 값을 꺼내서 쓰는 경우, TypeScript에서도 똑같이 해보겠습니다.

function extractAndConvert(obj: object, key: string) {
	return "Value" + obj[key]; // X type Error
}

type error입니다…!

이유는 obj에 key가 있을지 모르기 때문이죠! (이정도로 TypeScript는 철저합니다…!)

하지만 Generic을 이용해 이렇게 해결할 수 있습니다!

function extractAndConvert<T extends object, U extends keyof T>(obj: T, key: U) {
	return "Value" + obj[key]; // O
}

T type은 무조건 object이고 U type은 T type의 key 값이라는 것을 확실하게 해주었습니다.

이제 전혀 문제가 없습니다!

Object에서의 Generic활용

이번엔 객체에서의 Generic을 알아보겠습니다!

아래에 예시를 만들어 보겠습니다.

// 범용적인 DataStorage
class DataStorage {
	private data: any[] = [];

	addItem(item: any) {
		this.data.push(item);
	}

	removeItem(item: any) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

DataStorage는 보통 범용적으로 쓰이기 때문에 any를 적용하는것이 좋겠죠…? (그럼 TypeScript를 왜 쓰는거죠…?)

any를 적용하면 JavaScript와 완전 동일하게 작동하게 됩니다.

이 때, type을 지정하고 싶다면 아래와 같이 상속을 해야할 것입니다.

class DataStorage {
	private data: any[] = [];

	addItem(item: any) {
		this.data.push(item);
	}

	removeItem(item: any) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

class StringDataStorage extends DataStorage {
	constructor() {
		super();
	}

	addItem(item: string) {
		this.data.push(item);
	}

	removeItem(item: string) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

class NumberDataStorage extends DataStorage {
	constructor() {
		super();
	}

	addItem(item: number) {
		this.data.push(item);
	}

	removeItem(item: number) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

하지만 이런 상속과정도 너무 번거롭고 코드의 양도 상속하는 갯수 n개 만큼 n배로 늘어나게 됩니다…😫

하지만 Generic이라면 이런 문제를 한번에 해결할 수 있습니다…!

class DataStorage<T> {
	private data: T[] = [];

	addItem(item: T) {
		this.data.push(item);
	}

	removeItem(item: T) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

// T의 type이 string으로 정해집니다.
const stringDataStorage = new DataStorage<string>();
// T의 type이 number로 정해집니다.
const numberDataStorage = new DataStorage<number>();

stringDataStorage.addItem("str");
// stringDataStorage.addItem(9); X type error
stringDataStorage.getItems()[0].split(""); // O

numberDataStorage.addItem(9);
// numberDataStorage.addItem("str"); X type error

위처럼 객체를 만들때, T의 타입을 정해주면 T 타입으로 정해진 모든 타입에 똑같이 적용이 됩니다.

또한 T 타입이 정해졌기 때문에 return 되는 값도 T 타입이 되어 에디터가 return되는 결과물의 타입을 알 수 있게 되므로 자동완성 기능이 가능하여 생산성이 향상된다는 소소한 장점도 있습니다!

(“T”라는 문자는 제네릭 타입을 표현할 때 관용적으로 사용하는 타입 문자입니다.)

엣지케이스…

예외로, 타입은 컴파일 단계에서 검사하는 것이므로 런타임에서는 아래와 같은 억지스러운 코드는 막을 수 없습니다!

// 아주 악랄하게 type 검사를 피해가는 코드...
numberDataStorage.addItem("str" as any);

Generic vs Union

그렇다면 이런 생각이 들 수도 있습니다.

위와 같은 경우면 그냥 Union을 써도 되지 않을까…?

그래서 준비했습니다.

class DataStorage {
	private data: string[] | number[] | boolean[] = [];

	addItem(item: string | number | boolean) {
		this.data.push(item);
	}

	removeItem(item: string | number | boolean) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

const dataStorage = new DataStorage();

dataStorage.addItem("str");
dataStorage.addItem(12); // X error

위와 같은 class는 Generic처럼 범용적으로 쓰일 수 있을 것입니다.

하지만…! data에 셋 중 하나의 type 데이터가 들어가는 순간 문제가 발생합니다.

만약, 처음에 data안에 string이 들어가면 data: string[]으로 고정이 되어버리게 됩니다.

하지만,,, addItem은 string, number, boolean을 parameter로 그대로 받을 수 있죠…?

네! 예상하셨듯이 에러입니다!

그렇다면 이건 어떨 까요?

class DataStorage<T extends string | number | boolean> {
	private data: T[] = [];

	addItem(item: T) {
		this.data.push(item);
	}

	removeItem(item: T) {
		this.data.splice(this.data.indexOf(item), 1);
	}

	getItems() {
		return [...this.data];
	}
}

const stringDataStorage = new DataStorage<string>();

stringDataStorage.addItem("str");
// stringDataStorage.addItem(12); X type error

const numberDataStorage = new DataStorage<number>();

numberDataStorage.addItem(12);
// numberDataStorage.addItem("str"); X type error

객체 생성시에 T를 설정해주게 되면 T에 해당되는 모든 type들이 같아지기 때문에 Union type과 같은 문제가 발생하지 않습니다.

범용적이지만 훨씬 안정적이지요.

Generic Utillity Type

마지막으로 매우 유용하게 사용할 수 있는 Utillity Type 입니다.

저는 개인적으로 JavaScript에서 이런 code를 유용하게 썻습니다.

new를 한 것 처럼 완전히 새로운 객체를 생성 하거나 복사할 수 있기 때문이죠.

function createTodo(title, description, date) {
	let todo = {};

	todo.title = title;
	todo.description = description;
	toto.date = date;

	return todo;
}

function createTodoArr(title, description, date) {
	let todo = [];

	todo.push(title);
	todo.push(description);
	todo.push(date);

	return todo;
}

이것을 TypeScript에서 똑같이 구현한다고 했을땐,

interface Todo {
	title: string;
	description: string;
	date: Date;
};

function createTodo(title: string, description: string, date: Date): Todo {
	let todo: Todo = {}; // X type error

	todo.title = title;
	todo.description = description;
	toto.date = date;

	return todo;
}

간단히 생각했을 때는 위와 같은 형태의 코드를 만들게 됩니다.

물론, type error입니다! (저도 처음엔 많이 당황스러웠습니다…;;)

위에서 문제는 let todo: Todo인 상태에서 Todo형태의 객체가 아닌 빈 객체를 할당해줬기 때문입니다.

let todo: Todo || {} = {};

이렇게 해결할 수도 있겠지만, 이러면 Todo에 선언된 모든 property가 할당되지 않으면 type error가 되게 됩니다.

Partial

parameter의 값이 없을 수도 있기 때문에 좀 더 범용성 있게 함수를 만들기 위해서

Partial이라는 Generic Utillity type을 사용해보겠습니다.

interface Todo {
	title: string;
	description: string;
	date: Date;
};

function createTodo(title: string, description: string, date: Date): Todo {
	let todo: Partial<Todo> = {}; // O

	todo.title = title;
	todo.description = description;
	toto.date = date;

	return todo as Todo;
}

Partial을 감싸는 순간 Todo의 모든 property들은 optional이 되기 때문에 type error가 나지 않게 됩니다.

하지만 return하는 todo는 Todo 타입이어야 하기 때문에 Partial<Todo>를 Todo로 type casting해줘야 합니다.

이것으로 JavaScript의 로직을 재활용 할 수 있게 됐군요…!

Readonly

JavaScript에서는 객체를 const로 지정해 둔 다음 method를 이용해 마음껏 안의 데이터를 수정했습니다.

const test = [1,2,3];

test.pop();
console.log(test); // [1,2]

이러면 const의 의미가 굉장히 무색해집니다…!

constant이어야 하는 변수가 참조값만 constant고 안의 data들은 constant가 아니라니…!

하지만 진정한 의미의 Readonly가 등장했습니다.

const test: Readonly<number[]> = [1,2,3];

// test.pop(); X type error
// test.push(9); X type error

Readonly를 통해 객체 안의 data들이 전혀 수정되지 못하도록 확실하게 막아주고 있습니다.

이름값하는 Generic

Generic은 이름답게 여기저기서 정말 많이 유용하게 사용되고 있습니다.

특히 React에서 정말 정말 많이 쓰이는 만큼 정말 중요한 개념임에는 틀림없다고 생각합니다.

앞으로 Generic을 통해 저의 type 이해 범위를 좀 더 Generic하게 해보려고 합니다~~~!