10-1 제네릭 타입 이해하기

제네릭 타입이란, 인터페이스나 클래스, 함수, 타입 별칭 등에 사용할 수 있는 기능으로, 해당 심벌의 타입을 미리 지정하지 않고 한 가지 타입보다 여러 가지 타입에서 동작하는 컴포넌트를 생성할 때 사용한다.

string, number 등으로 특정하지 않고, T로 지정해 제네릭 타입을 만든다.

// 제네릭 인터페이스
interface IValuable<T> {
    value: T
}

// 제네릭 함수
function identity<T>(arg: T): T {
  return arg
}

// 제네릭 함수 - 화살표 함수
const fn1 = <T>(a: T): void => {}
const fn2 = <T, Q>(a: T, b: Q): void => {}

// 제네릭 클래스
class Valuable<T> {
  constructor(public value: T) {}
}

// 제네릭 타입 별칭
type IVable<T> = {
  value: T
}

// 제네릭 타입 별칭
type Type1Func<T> = (T) => void
type Type2Func<T, Q> = (T, Q) => void
type Type3Func<T, Q, R> = (T, Q) => R

 

  • 제네릭 동작 방식
function getText<T>(text: T): T {
  return text
}

getText<string>('hi')

function getText<string>(text: string): string {
  return text
}

 

제네릭 사용하기

  1. 제네릭 인터페이스를 정의한다.
  2. 제네릭 클래스는 자신이 가진 타입 변수 T를 인터페이스 쪽 제네릭 타입 변수로 넘긴다.
  3. 제네릭 함수는 자신의 타입 변수 T를 제네릭 인터페이스의 타입 변수 쪽으로 넘긴다.

 

제네릭 타입도 타입 추론이 가능하다.

// 1번
interface IValuable<T> {
  value: T
}

// 2번
class Valuable<T> implements IValuable<T> {
  constructor(public value: T) {}
}

// 3번
const printValue = <T>(o: IValuable<T>): void => console.log(o.value)

printValue(new Valuable<number>(1))           // 1
printValue(new Valuable<string>('hello'))     // hello
printValue(new Valuable<boolean>(true))       // true
printValue(new Valuable<number[]>([1, 2, 3])) // [1, 2, 3]
printValue(new Valuable(['hello', 'world']))  // ['hello', 'world']

 


10-2 제네릭 타입 제약

제네릭 타입 제약은 타입 변수에 적용할 수 있는 타입의 범위를 한정하는 기능이다.

원하지 않는 속성에 접근하는 것을 막거나 특정 속성만 접근할 수 있다.

<최종 타입1 extends 타입1, 최종 타입2 extends 타입2>(a: 최종 타입1, b: 최종 타입2, ...) => { }
interface LengthCheck {
  length: number
}

function logText1<T>(text: T): T {
  console.log(text.length) // Property 'length' does not exist on type 'T'.
  return text
}

function logText2<T extends LengthCheck>(text: T): T {
  console.log(text.length)
  return text
}

logText2(10)        // Argument of type 'number' is not assignable to parameter of type 'LengthCheck'.
logText2([1, 2, 3]) // 3

 

new 타입 제약

팩토리 함수는 new 연산자를 사용해 객체를 생성하는 기능을 하는 함수이다. 보통 팩토리 함수는 객체를 생성하는 방법이 복잡해 이를 단순화하려는 목적으로 구현한다.

타입스크립트 컴파일러는 '타입의 타입'을 허용하지 않아 오류가 발생하기 때문에 '타입의 타입' 구문을 만들기보다 new() 메서드 형태로 표현해야 한다.

// This expression is not constructable. Type '{}' has no construct signatures.
const create1 = <T>(type: T): T => new type()

const create2 = <T extends {new(): T}>(type: T): T => new type()
const create3 = <T>(type: new() => T): T => new type()

 

결론적으로, {new(): T}와 new() => T는 같은 의미이다

new 연산자를 type에 적용하면서 type의 생성자 쪽으로 매개변수를 전달해야 할 때 아래처럼 구문을 사용한다.

const create = <T>(type: {new(...args: any[]): T}, ...args: any[]): T => new type(...args)

class Point {constructor(public x: number, public y: number) {}}
[
  create(Date),        // 2023-07-07T06:39:46.976Z
  create(Point, 0, 0), // Point { x: 0, y: 0 }
].forEach(s => console.log(s))

 

인덱스 타입 제약

keyof 연산자는 객체의 일부 속성들만 추려서 단순한 객체를 만들 때 사용한다.

keyof T 형태로 타입 제약을 설정할 수 있고, keys 값을 넘겨줄 수 있다.

<T, K extends keyof T>
const pick = <T, K extends keyof T>(obj: T, keys: K[]) =>
  keys.map(key => ({[key]: obj[key]}))
    .reduce((result, value) => ({...result, ...value}), {})

const obj = {name: 'Jane', age: 22, city: 'Seoul', country: 'Korea'}

console.log(pick(obj, ['name', 'age']))   // { name: 'Jane', age: 22 }

//Type '"name1"' is not assignable to type '"name" | "age" | "city" | "country"'. Did you mean '"name"'?
console.log(pick(obj, ['name1', 'age1']))

 


10-3 대수 데이터 타입

타입스크립트에서 대수 데이터 타입은 '합집합 타입(union type)'과 '교집합 타입(intersection type)' 두 가지 종류가 있다.

함수형 언어들은 상속에 의존하는 타입보다 대수 데이터 타입을 선호한다.

 

합집합 타입 (|)

합집합 타입은 '또는(or)'의 의미인 '|' 기호로 다양한 타입을 연결해서 만든 타입이다.

type NumberOrString = number | string

let ns: NumberOrString = 1
ns = 'hello'

console.log(ns) // hello

 

교집합 타입 (&)

교집합 타입은 '이고(and)'의 의미인 '&' 기호로 다양한 타입을 연결해서 만든 타입이다.

type INameale = { name: string }
type IAgeable = { age: number }

const NameAndAge: INameale & IAgeable = {name: 'Jane', age: 22}
console.log(NameAndAge) // { name: 'Jane', age: 22 }

// type '{ name: string; }' is not assignable to type 'INameale & IAgeable'. Property 'age' is missing in type '{ name: string; }' but required in type 'IAgeable'.
const NameAndAge1: INameale & IAgeable = {name: 'Jane'}

 

합집합 타입 구분하기

아래와 같은 코드가 있다고 가정했을 때, shape이 어떤 타입의 객체인지 구분할 수 없기 때문에 합집합 타입의 각각을 구분할 수 있게 하는 '식별 합집합' 구문을 제공한다.

interface ISquare {size: number}
interface IRectangle {width: number, height: number}
interface ICircle {radius: number}

type IShape = ISquare | IRectangle | ICircle

const calcArea= (shape: IShape): number => {
  // shape 객체가 ISquare | IRectangle | ICircle 3가지 중에 무엇인지 알 수 없음
  return 0
}

 

식별 합집합 구문

합집합 타입을 구성하는 인터페이스들은 모두 똑같은 이름의 속성을 가지고 있어야 한다.

calcArea 함수의 switch문을 보면 tag 공통 속성이 있다. 공통 속성이 없으면 각각의 타입을 구분할 방법이 없다.

interface ISquare {tag: 'square', size: number}
interface IRectangle {tag: 'rectangle', width: number, height: number}
interface ICircle {tag: 'circle', radius: number}

type IShape = ISquare | IRectangle | ICircle

const calcArea = (shape: IShape): number => {
  switch(shape.tag) {
    case 'square': return shape.size * shape.size
    case 'rectangle': return shape.width * shape.height
    case 'circle': return Math.PI * shape.radius * shape.radius
  }
}

const square: ISquare = {tag: 'square', size: 10}
const rectangle: IRectangle = {tag: 'rectangle', width: 4, height: 2}
const circle: ICircle = {tag: 'circle', radius: 3}

console.log(calcArea(square), calcArea(rectangle), calcArea(circle)) // 100 8 28.274333882308138

 


10-4 타입 가드

타입 가드란, 데이터의 타입을 알 수 없거나 될 수 있는 타입이 여러 가지일 경우, 조건문을 통해서 타입을 좁혀나가는 것을 의미한다. 또한 타입을 변환하지 않은 코드 때문에 프로그램이 비정상으로 종료되는 상황을 보호해 준다.

조건문에서 typeof 또는 instanceof를 사용하면, 타입스크립트는 해당 조건문 블록 내에서는 해당 변수의 타입이 다르다는 것(= 좁혀진 범위의 타입)을 이해할 수 있다.

 

합집합 타입으로 FlyOrSwim 함수를 만들면 타입이 Bird인지 Fish인지 알 수 없기 때문에 오류가 발생하고, 이를 방지하기 위해서는 타입 가드를 사용해야 한다.

class Bird {fly() {console.log('fly')}}
class Fish {swim() {console.log('swim')}}

const FlyOrSwim = (o: Bird | Fish): void => {
  // Property 'fly' does not exist on type 'Bird | Fish'. Property 'fly' does not exist on type 'Fish'.
  o.fly()
}

 

instanceof 연산자

instanceof 연산자는 2개의 피연산자가 필요하다.

객체 instanceof 타입 // boolean 타입의 값 반환
class Bird {fly() {console.log('fly')}}
class Fish {swim() {console.log('swim')}}

const FlyOrSwim = (o: Bird | Fish): void => {
  if(o instanceof Bird) {
    o.fly()
  } else if(o instanceof Fish) {
    o.swim()
  }
}

[new Bird, new Fish].forEach(FlyOrSwim) // fly swim

 

is 연산자를 활용한 사용자 정의 타입 가드 함수 제작

instanceof처럼 타입 가드 기능을 하는 함수를 만들기 위해서는 함수의 반환 타입 부분에 is 연산자를 사용해야 한다.

변수 is 타입
class Bird {fly() {console.log('fly')}}
class Fish {swim() {console.log('swim')}}

const isFlyable = (o: Bird | Fish): o is Bird => {
  return o instanceof Bird
}

const isSwimmable = (o: Bird | Fish): o is Fish => {
  return o instanceof Fish
}

const FlyOrSwim = (o: Bird | Fish) => {
  if(isFlyable(o)) {
    o.fly()
  } else if(isSwimmable(o)) {
    o.swim()
  }
}

[new Bird, new Fish].forEach(FlyOrSwim) // fly swim

 

typeof 연산자

typeof 연산자는 객체 데이터를 객체 타입으로 변환해 준다.

 

charAt(): 문자열에서 지정된 위치에 있는 문자 반환

valueAt(): 넘버가 가지고 있는 값 반환

function doSomething(x: string | number) {
    if(typeof x === 'string') {
        console.log(x.charAt(0))
    } else if(typeof x === 'number') {
        console.log(x.valueOf())
    }
}

doSomething('abc')     // a
doSomething(10)        // 10
doSomething([1, 2, 3]) // Argument of type 'number[]' is not assignable to parameter of type 'string | number'.

 


10-5 F-바운드 다형성

this 타입과 F-바운드 다형성

타입스크립트에서 this 키워드는 타입으로 사용된다.

this가 타입으로 사용되면 객체지향 언어에서 의미하는 다형성(polymorphism) 효과가 나타나고, 일반적인 다형성과 구분하기 위해 this 타입으로 인한 다형성을 'F-바운드 다형성(F-bound polymorphism)' 이라고 한다.

 

(1) F-바운드 타입

F-바운드 타입이란, 자신을 구현하거나 상속하는 서브타입(subtype)을 포함하는 타입을 말한다.

// 특별히 자신을 상속하는 타입이 포함되어 있지 않은 일반 타입
interface IValueProvider<T> {
  value(): T
}

// add 메서드가 내가 아닌 나를 상속하는 타입을 반환하는 F-바운드 타입
interface IAddable<T> {
  add(value: T): this
}

// 메서드의 반환 타입이 this 이므로 F-바운드 타입
interface IMultiplyable<T> {
  multiply(value: T): this
}

 

(2) IValueProvider<T> 인터페이스의 구현

_value 속성을 private으로 만들어 Calculator를 사용하는 코드에서 _value 속성이 아닌 value() 메서드로 접글할 수 있게 설계되었다.

// F-바운드 타입이 아닌 일반 타입의 인터페이스 구현
class Calculator implements IValueProvider<number> {
  constructor(private _value: number = 0) {}
  value(): number {return this._value}
}

class StringComposer implements IValueProvider<string> {
  constructor(private _value: string = '') {}
  value(): string{return this._value}
}

 

(3) IAddable<T>와 IMultiplyable<T> 인터페이스 구현

add 메서드는 클래스의 this 를 반환하는데 이는 메서드의 체인 기능을 구현하기 위함이다.

같은 방법으로 multiply 메서드를 구현한다.

class Calculator implements IValueProvider<number>, IAddable<number>, IMultiplyable<number> {
    constructor(private _value: number = 0) { }
    value(): number {return this._value}

    // 클래스의 this 를 반환 (메서드 체인 기능을 구현하기 위함)
    add(value: number): this {
        this._value = this._value + value
        return this
    }

    multiply(value: number): this {
        this._value = this._value * value
        return this
    }
}

 

결과 

import 시키지 않은 전체 코드

// 특별히 자신을 상속하는 타입이 포함되어 있지 않은 일반 타입
interface IValueProvider<T> {
  value(): T
}

// add 메서드가 내가 아닌 나를 상속하는 타입을 반환하는 F-바운드 타입
interface IAddable<T> {
  add(value: T): this
}

// 메서드의 반환 타입이 this 이므로 F-바운드 타입
interface IMultiplyable<T> {
  multiply(value: T): this
}

class Calculator implements IValueProvider<number>, IAddable<number>, IMultiplyable<number> {
  constructor(private _value: number = 0) { }
  value(): number {return this._value}

  // 클래스의 this 를 반환 (메서드 체인 기능을 구현하기 위함)
  add(value: number): this {
    this._value = this._value + value
    return this
  }

  multiply(value: number): this {
    this._value = this._value * value
    return this
  }
}

const value = (new Calculator(1))
    .add(2) // 3
    .add(3) // 6
    .multiply(4) // 24
    .value()

console.log(value) // 24

 


10-6 nullable 타입과 프로그램 안전성

nullable 타입이란?

자바스크립트와 타입스크립트는 변수가 초기화되지 않으면 undefined 이라는 값을 기본으로 지정한다.

타입스크립트에서 undefine 값의 타입은 undefined이고, null 값의 타입은 null이다. 하지만 이 둘은 사실상 같은 것이므로 서로 호환되고, undefined 변수에 null 값을 지정할 수 있고, null 변수에 undefined 값을 지정할 수 있다.

즉, undefined 타입 + null 타입을 nullable 타입이라고 한다. 

 

그러나 nullable 타입은 프로그램이 동작할 때 프로그램을 비정상으로 종료시키는 주요 원인이 되기 때문에 프로그램의 안전성을 헤치게 된다.

 

주의: tsconfig.json 의 strict: true로 설정 시에는 호환 안됨

let u: undefined = undefined
let n: null = null

u = null
n = undefined

console.log(u) // null
console.log(n) // undefined

 

type nullable = undefined | null

const nullable: nullable = undefined

 

옵션 체이닝 연산자 (?.)

변수가 선언만 되었을 뿐 어떤 값으로 초기화되지 않으면 코드를 작성할 때는 문제가 없지만, 실제로 코드를 실행하면 런타임 오류가 발생해 프로그램이 비정상으로 종료한다.

이런 오류는 프로그램의 안전성을 해치므로 '옵션 체이닝' 연산자나 '널 병합 연산자'를 제공한다.

 

자바스크립트는 옵션 체이닝인 ?. 연산자를 표준으로 채택했고, 타입스크립트는 버전 3.7.2부터 사용가능하다.

interface IPerson {
  name: string
  age?: number
}

let person: IPerson

console.log(person.name)  // 런타임 오류, TypeError: Cannot read properties of undefined (reading 'name')
console.log(person?.name) // undefined
type ICoordinates = {longitude: number}
type ILocation = {country: string, coords?: ICoordinates}
type IPerson = {name: string, location?: ILocation}

let person: IPerson = {name: 'Jack'}
let longitude = person?.location?.coords?.longitude

console.log(longitude) // undefined

 

널 병합 연산자 (??)

자바스크립트는 널 병합 연산자인 ?? 연산자를 표준으로 채택했고, 타입스크립트는 버전 3.7.2부터 사용가능하다.

옵션 체이닝 연산자 부분이 undefined가 되면 널 병합 연산자가 동작해 0을 반환한다.

type ICoordinates = {longitude: number}
type ILocation = {country: string, coords?: ICoordinates}
type IPerson = {name: string, location?: ILocation}

let person: IPerson = {name: 'Jack'}
let longitude = person?.location?.coords?.longitude ?? 0

console.log(longitude) // 0

 

nullable 타입의 함수형 방식 구현

Option 타입 객체는 Option.Some(값) 또는 Option.None 형태로만 생성할 수 있다.

import {Some} from './Some'
import {None} from './None'

class Option {
  private constructor() {}
  static Some<T>(value: T) {return new Some<T>(value)}
  static None = new None()
}

let o: Option = Option.Some(1)
let n: Option = Option.None()

 

(1) Some 클래스 구현

Some 클래스의 사용자는 항상 getOrElse 메서드를 통해 Some 클래스에 담긴 값을 얻어야 한다.

Some 클래스의 사용자는 value 값을 변경하려면 항상 map 메서드를 사용해야 한다.

interface IValuable<T> {
  getOrElse(defaultValue: T)
}

interface IFunctor<T> {
  map<U>(fn: (value: T) => U)
}

class Some<T> implements IValuable<T>, IFunctor<T> {
  constructor(private value: T) {}
  
  getOrElse(defaultValue: T) {
    return this.value ?? defaultValue
  }
  
  map<U>(fn: (T) => U) {
    return new Some<U>(fn(this.value))
  }
}

 

(2) None 클래스 구현

None 클래스는 nullable 타입의 값을 의미하므로, nullable 값들이 map의 콜백 함수에 동작하면 프로그램이 비정상으로 종료될 수 있다.

interface IValuable<T> {
  getOrElse(defaultValue: T)
}

interface IFunctor<T> {
  map<U>(fn: (value: T) => U)
}

type nullable = undefined | null

class None implements IValuable<nullable>, IFunctor<nullable> {
  getOrElse<T>(defaultValue: T | nullable) {
    return defaultValue
  }
  
  map<U>(fn: (T) => U) {
    return new None
  }
}

 

(3) Some과 None 클래스 사용

Some 타입에 설정된 값 1은 map 메서드를 통해 2로 바뀌었고, getOrElse 메서드에 의해 value 변수에 2가 저장됐다.

 

None 타입 변수 n 은 map 메서드를 사용할 수는 있지만 이 map 메서드의 구현 내용은 콜백 함수를 실행하지 않고 단순히 None 타입 객체만 반환하기 때문에 getOrElse(0) 메서드가 호출되어 전달받은 0 이 저장됐다.

 

FIXME:: @ts-ignore 없으면 오류남

// @ts-ignore
class Option {
  private constructor() {}
  static Some<T>(value: T) {return new Some<T>(value)}
  static None = new None()
}

// @ts-ignore
let m = Option.Some(1)
let value = m.map(value => value + 1).getOrElse(1)
console.log(value) // 2

// @ts-ignore
let n = Option.None
value = n.map(value => value + 1).getOrElse(0)
console.log(value) // 0

 

Option 타입과 예외 처리

Option 타입은 부수 효과가 있는 불순 함수를 순수 함수로 만드는데 효과적이다.

 

아래 코드는 parseInt 의 반환값이 NaN 인지에 따라 Option.None 혹은 Option.Some 타입의 값을 반환한다.

import {IFunctor} from './option/IFunctor'
import {IValuable} from './option/IValuable'
import {Option} from './option/Option'

const parseNumber = (n: string): IFunctor<number> & IValuable<number> => {
    const value = parseInt(n)
    return isNaN(value) ? Option.None : Option.Some(value)
}

 

값이 정상 변환되면 map 메서드가 동작하여 4가 출력되고, 비정상이면 getOrElse(0) 이 동작해 0 이 리턴된다.

import {IFunctor} from './option/IFunctor'
import {IValuable} from './option/IValuable'
import {Option} from './option/Option'

const parseNumber = (n: string): IFunctor<number> & IValuable<number> => {
    const value = parseInt(n)
    return isNaN(value) ? Option.None : Option.Some(value)
}

let value = parseNumber('1')
    .map(value => value + 1) // 2
    .map(value => value * 2) // 4
    .getOrElse(0)
console.log(value) // 4

value = parseNumber('hello')
    .map(value => value + 1) // 콜백 함수 호출안됨
    .map(value => value * 2) // 콜백 함수 호출안됨
    .getOrElse(0)
console.log(value) // 0

 

JSON 포맷 문자열에 따라 예외를 발생시키는 부수 효과가 있는 불순 함수를 try/catch 문과 함께 Option 을 활용하여 순수 함수로 전환시킨 예시이다.

import {IFunctor} from './option/IFunctor'
import {IValuable} from './option/IValuable'
import {Option} from './option/Option'

const parseJeon = <T>(json: string): IValuable<T> & IFunctor<T> => {
    try {
        const value = JSON.parse(json)
        return Option.Some<T>(value)
    } catch (e) {
        return Option.None
    }
}

const json = JSON.stringify({name: 'Jack', age: 32})
let value = parseJeon(json).getOrElse({})
console.log(value)  // { name: 'Jack', age: 32 }

value = parseJeon('hello').getOrElse({})
console.log(value) // {}

 

 

 

참고자료

Do it! 타입스크립트 프로그래밍

+ Recent posts