ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 타입스크립트 프로그래밍 4장
    Dev 2023. 3. 3. 14:41


    💡 타입스크립트의 함수에 대하여 다룬다.

    4.1 함수 선언과 호출

    타입스크립트에서의 함수

    자바스크립트에서 함수는 일급 객체이다.

    즉, 함수를 객체처럼 사용할 수 있다는 의미이다. 따라서 함수를 변수에 할당하거나, 함수를 반환하거나 하는 등의 작업을 할 수 있다.

    타입스크립트는 이러한 자바스크립트 함수의 특성을 타입에 잘 녹여냈다

     

    타입스크립트로 선언한 함수의 예를 보면 다음과 같다.

    function add(a:number,b:number){
    	return a + b
    }
    

    보통 타입스크립트에서 함수를 선언할때 매개변수 타입은 지정하고, 반환 타입은 지정하지 않는다.

    왜냐하면 반환 타입은 타입스크립트가 충분히 추론할 수 있기 때문에 굳이 개발자가 직접 지정해주는 수고를 할 필요가 없다. 물론 지정하고자 한다면, 아래와 같이 타입 지정 또한 가능하다.

    // 매개변수 뒤에 타입을 선언하여 반환 타입을 지정할 수 있다.
    function add(a:number, b:number):number {
    	return a + b
    }
    

     

    함수의 선언

    자바스크립트에서 함수를 선언할때 함수 표현식, 함수 선언식, 화살표 함수 등의 여러가지 방법들을 타입스크립트에서도 동일하게 지원한다.

    // 함수 표현식
    function greet(name: string){
    	return 'hello ' + name
    }
    
    // 함수 표현식
    const greet2 = function(name: string){
    	return 'hello ' + name
    }
    
    // 화살표 함수 표현식
    const greet3 = (name: string) => {
    	return 'hello ' + name
    }
    
    // 단축형 화살표 함수 표현식
    const greet4 = (name: string) => 'hello ' + name
    
    // 함수 생성자
    const greet5 = new Function('name', 'return "hello " + name')
    
    

    위의 방법들 중 생성자를 이용한 방법을 제외하고는 타입 안정성이 보장되기 때문에 함수를 선언할때는 생성자를 사용한 방법을 제외하고 마음껏 원하는대로 함수를 작성하도록 한다.

     

    함수의 호출

    이렇게 선언한 함수를 호출할 때에는 별도의 타입 정보를 제공하지 않아도 된다.

    함수를 호출하면 함수 선언 시 지정한 타입을 기반으로 타입스크립트가 타입 체크를 하며, 호환되지 않는 경우 타입 에러를 발생시킨다.

    ex) 2개의 인수가 필요한데 1개만 전달, number 타입의 매개변수에 string 타입의 인자를 전달 할 수 없음

    이렇게 친절하게 알려주는 에러 메시지를 기반으로 런타임에서가 아닌 컴파일 타임에서 에러를 확인하고 수정할 수 있으며, 인수를 잘못된 타입으로 넣는 등의 개발자의 멍청한 실수를 방지할 수 있다.

     

    선택적 매개변수

    타입 선언 시 선택적 타입 지정할 수 있는 것과 같이 함수의 매개변수 또한 선택적 매개변수로 지정할 수 있다.

    선택 타입과 같이 매개변수 뒤에 “?”를 붙여주면 선택 매개변수로 지정할 수 있다.

    주의할 점은 선택적 매개변수는 필수 매개변수 뒤에 작성해야한다.

    function log(message: string, userId?: string ){
    	let time = new Date().toLocaleTimeString()
    	console.log(time,message,userId || 'Not signed in')
    }
    
    log('Page loaded') // 오후 9:40:15 Page loaded Not signed in
    
    log('User signed in','ellen') // 오후 9:40:15 User signed in ellen 
    
    

    선택적 매개변수는 위와 같이 ? 를 사용하여 지정할 수도 있지만, 자바스크립트의 매개변수 기본값을 설정하는 기능을 이용하여 선택적 매개변수로 만들 수 있다.

    function log(message: string, userId = 'Not signed in' ){
    	let time = new Date().toLocaleTimeString()
    	console.log(time,message,userId)
    }
    
    log('Page loaded') // 오후 9:40:15 Page loaded Not signed in
    
    log('User signed in','ellen') // 오후 9:40:15 User signed in ellen
    

    위와 같이 기본값을 지정하여 선택적 매개변수로 작성하면 ?를 사용한 방식 보다 간결하고, 가독성을 높일 수 있다.

    또 매개변수의 기본값을 지정하는 경우 매개변수의 작성 순서가 중요하지 않으며, 타입스크립트가 기본값으로 지정한 값을 기반으로 해당 매개변수의 타입을 추론하기 때문에 선택적 매개변수에 대한 타입 지정 또한 걱정하지 않아도 된다.

     

    호출 시그니처

    앞선 코드에서는 함수의 매개변수와 반환 데이터의 타입 지정에 대해 다뤘습니다.

    만약 동일한 타입의 매개변수를 받고, 동일한 반환 타입을 가진 함수를 여러 개 작성한다고 가정해보면, 함수 하나 하나에 동일한 타입을 작성해줘야 할까요?

    아닙니다. 타입스크립트는 함수 전체에 대한 타입도 지정할 수 있습니다.

    변수에 string 이라고 타입을 지정할 수 있듯이, 함수도 마찬가지로 타입을 지정해줄 수 있습니다. 이러한 함수의 타입을 “호출 시그니처” 또는 “타입 시그니처” 라고 합니다.

    // 단축형 호출 시그니처
    type Sum = ( a: number, b: number ) => number
    
    // 전체 호출 시그니처
    type Sum = {
    	( a: number, b: number ):number
    }
    

    위에서 다뤘던 함수를 호출 시그니처를 이용하여 타입을 다시 지정해보면 아래와 같다.

    type Log = (message: string, userId?: string ) => void
    
    let log: Log = (message, userId = 'Not signed in'){
    	let time = new Date().toISOString()
    	console.log(time, message, userId)
    } 
    
    // log 함수에 대해 Log 타입을 지정해주었기 때문에 함수 선언 시 매개변수의 타입을 지정해주지 않아도 된다. 
    

    위와 같이 매개변수에 직접적으로 타입을 지정해주지 않아도 타입스크립트가 문맥상 타입을 추론하는 것을 “문맥적 타입화” 라고 한다.

     

    오버로드된 함수 타입

    오버로드된 함수가 무슨 의미일까?

    타입스크립트 프로그래밍에서는 오버로드된 함수를 호출 시그니처가 여러개인 함수라고 소개하고 있다.

    이렇게 읽어서는 이해하기가 살짝 난해하다. 코드로 이해해 보기로 하자

    ❓ “여행 예약 API 함수를 작성해야한다. 단 함수는 왕복 예약과 함께 편도인 경우도 함께 처리할 수 있어야한다.”

    // 오버로드된 함수 타입
    type Reserve = {
      (from: Date, to: Date, destination: string): Reservation; // 왕복
    	(from: Date, destination: string): Reservation; // 편도
    };
    
    // 오버로드 된 함수
    let reserve: Reserve = (from, to, destination) => {
      return `${destination} : ${from} ~ ${to}`;
    };
    
    1. 우선 왕복과 편도 각 목적에 맞게 타입을 작성
    2. Reserve 타입은 이제 두개의 호출 시그니처를 가지게 되었다.
    3. Reserve가 타입으로 지정된 reserve 함수는 두개의 호출 시그니처를 가진 함수가 되었다.
    4. 여러개의 호출 시그니처를 가지는 함수는? ⇒ 오버로드된 함수

    하지만 위와 같이 타입을 작성하고 reserve 함수의 선언부를 보면 “is not assignable to type Reserve” 라는 에러를 내뿜고있다.

    그 이유는 예를 들어 왕복에 해당하는 타입만 Reserve의 호출 시그니처로 지정했을 경우 타입스크립트는 reserve 함수의 매개변수 타입은 from: Date, to: Date, destination: string 일 것이라고 추론할 수 있었을 것이다. 하지만 호출 시그니처가 두개가 되면서, 타입스크립트는 타입 추론을 할 수 없게되었다.

    “두개의 호출 시그니처를 합친 타입을 매개변수 타입으로 추론하면 되잖아?” 라고 생각할 수 있으나, 타입스크립트는 인공지능이 아니니 개발자가 해당 함수가 어떻게 실행될 것인지 알려주어야한다.

    이렇게 함수 선언부에 어떻게 실행될 것인지 알려주는 작업은 간단히 생각해서 호출 시그니처들을 모두 만족하도록 매개변수의 타입을 지정해주면 된다.

    let reserve: Reserve = (from: Date, toOrDestination: Date | string, destination?: string) => {
    		if(toOrDestination instanceOf Date && destination !== undefined){
    // 왕복
    	}
    	if(typeof toOrDestination === 'string'){
    
    	}
    };
    

    함수의 오버로드 정리

    1. 필요한 타입들을 모두 작성한다.
    2. 작성한 타입들을 모두 만족하도록 함수를 선언한다.
    3. 용도에 따라 함수를 호출하여 사용할 수 있다.

    4.2 다형성

    우리가 즐겨 사용하는 filter 기능을 하는 함수를 직접 구현해야한다면 타입을 어떻게 작성해주어야할지 생각해보자.

    보통 filter 메서드를 사용할때를 생각해보면 filter에 사용되는 데이터 타입은 스트링 배열, 숫자 배열, 객체 배열 등 배열 형태로 된 모든 데이터를 다룰 수 있어야한다.

    이러한 특징을 타입으로 작성해야한다면 어떻게 해야할까? 위에서 다룬 오버로딩을 사용하여 아래와 같이 구현할 수도 있을 것이다.

    // 직접 구현한 filter 함수
    function filter(array,f){
    	let result = []
    
    	for (let i = 0; i<array.length; i++){
    		let item = array[i]
    
    		if(f(item)){
    			result.push(item)
    		}
    	}
    }
    
    type Filter = {
    	(array:number[], f:(item:number)=> boolean):number[]
    	(array:string[], f:(item:string)=> boolean):string[]
    }
    

    숫자와 문자인 경우에는 문제가 되지 않을 것이다. 하지만 객체 배열인 경우에는? object[]로 타입을 지정해주어야할까?

    직접 object[]로 타입을 지정해보면 알 수 있겠지만 이는 무의미한 타입 지정이라고 볼 수 있다.

     

    왜냐하면 object[]와 같이 지정해주게 되면 인자로 들어온 배열의 객체 프로퍼티에 접근하려하면 타입스크립트는 해당 프로퍼티가 object 타입에는 존재하지 않는다고 타입 에러를 발생시키기 때문이다. 그렇다고 들어오는 모든 데이터 타입에 해당하는 타입을 일일이 작성할 수도 없는 노릇이다.

     

    이러한 상황을 해결하기 위해 타입스크립트에는 제네릭이 존재한다. 제네릭은 타입을 매개변수화 하는 것이라고 할 수 있다.

    지금 당장은 무슨 타입인지 확정할 수 없으니, 해당 타입이 지정된 함수가 호출될때 들어오는 데이터의 타입으로 타입이 확정되도록 하는 기능이다.

     

    그럼 제네릭을 사용해서 Filter 타입을 수정해보도록하자.

    type Filter = {
    	<T>(array:T[], f:(item:T)=> boolean):T[]
    }
    

    제네릭을 사용할때에는 위와 같이 <>를 사용하여 제네릭 타입임을 선언한다. 또 이러한 제네릭은 아무 위치에서나 선언할 수 있는 것이 아닌, 한정된 위치에서만 선언할 수 있다.

     

    제네릭 타입의 결정

    // 호출 시그니처에 한정된 제네릭
    type Filter = {
    	<T>(array:T[], f:(item:T)=> boolean):T[]
    }
    
    let filter:Filter = (array,f)=> ... // 들어온 array 타입으로 결정
    
    // Filter 타입에 한정된 제네릭
    type Filter<T> = {
    	(array:T[], f:(item:T)=> boolean):T[]
    }
    
    let filter:Filter<number> = (array,f)=> ... // number 타입으로 결정
    

    제네릭의 선언 위치에 따라 완전히 타입스크립트의 추론에 맡기거나, 함수 실행 시 인자를 넣어주는 것과 같이 함수 선언 시 <> 안에 타입을 넣어주어 해당 타입으로 결정할 수 있다.

     

    제네릭 타입의 선언 위치

    // 1. 전체 호출 시그니처의 개별 시그니처 한정 제네릭 => 함수 호출 시 타입 한정
    type Filter = {
    	<T>(array: T[], f:(item:T)=> boolean): T[]
    }
    
    // 2. 전체 호출 시그니처 한정 제네릭 => 함수 선언 시 타입 한정
    type Filter<T> = {
    	(array: T[], f:(item:T)=> boolean): T[]
    }
    
    // 3. 1번과 동일하지만 전체 호출 시그니처로 작성한 타입이 아닌 단축 호출 시그니처 => 함수 호출 시 타입 한정
    type Filter = <T>(array: T[], f:(item:T)=> boolean)=> T[]
     
    // 4. 2번과 동일하지만 전체 호출 시그니처로 작성한 타입이 아닌 단축 호출 시그니처 => 함수 선언 시 타입 한정
    type Filter<T> = (array: T[], f:(item:T)=> boolean)=> T[]
    
    // 5. 시그니처 한정 함수 호출 시그니처 => 함수 호출 시 타입 한정 
    function filter<T>(array: T[], f:(item:T)=> boolean): T[]{}
    
    

     

    한정된 다형성

    위에서 다룬 오버로딩과 제네릭을 사용하면 함수의 다형성을 강화할 수 있다.

    그런데 만약 제네릭이지만 들어오는 모든 타입을 허용하는게 아닌 특정 타입으로 한정하고 싶다면 어떻게 해야할까?

    코드 예시로 제네릭이면서도 가능한 타입의 제한을 두는 방법을 알아보자

    아래와 같은 Node 타입들이 있다.

    type TreeNode = {
    	value: string
    }
    
    // LeafNode는 TreeNode의 타입과 LeafNode 안에 작성한 타입을 모두 가진다.
    type LeafNode = TreeNode & {
    	isLeaf: true
    }
    
    // InnnerNode는 TreeNode의 타입과 InnnerNode 안에 작성한 타입을 모두 가진다.
    type InnnerNode = TreeNode & {
    	children: [TreeNode] | [TreeNode, TreeNode]
    }
    

    TreeNode의 서브타입을 인자로 받으면 해당 인자 타입에 해당하는 값을 반환하는 함수를 작성하고 싶다면 아래와 같이 작성해줄 수 있다.

    function mapNode<T extends TreeNode>(node: T, f: (value: string) => string ):T {
    	return {...node, value: f(node.value)}
    }
    

    위 코드에서 제네릭인 T는 TreeNode를 상속 받는다. 이렇게 타입을 상속 받게 함으로서 상속 받은 타입으로 제네릭 타입으로 지정될 수 있는 타입의 범위를 제한할 수 있다.

    그렇다면 왜 이런 타입 제한을 사용하는 것일까

    1. TreeNode의 서브타입이 들어올 것으로 예상하지만 타입을 한정하지 않고 T만 사용하는 경우 사용자가 string, number를 넣어도 타입 에러는 발생하지 않을 것이며, node.value가 string.value와 같이 접근하게 되므로 에러가 발생하게 된다.
    2. 애초부터 제너릭을 사용하지 않고 T를 TreeNode로 지정해버리게 되면 기존의 함수 목적과는 달리 반환 타입이 TreeNode로 반환되게 될 것이다.

    이러한 문제들을 방지하기 위해 타입 제한을 두며, 타입 제한을 하게 되면 사용자는 보다 명확한 사용법을 유추할 수 있고 개발자는 코드 리딩 시간을 단축 시킬 수 있을 것이다. (함수가 하는 일과 반환하는 값이 보다 명확하기 때문)

    만약 타입 제한을 여러개 두고 싶다면 아래 코드와 같이 작성할 수 있다.

    type HasSides = {numberOfSides: number};
    type SidesHaveLength = {sideLength: number};
    
    function longPerimeter<Shape extends HasSides & SidesHaveLength>(s: Shape):Shape{
    	console.log(s.numberOfSides * s.sideLength)
    	return s
    }
    

    위의 TreeNode의 서브타입들에서 &를 사용하여 TreeNode와 작성 타입을 합쳐준 것과 같이 작성해주면 여러개의 타입 제한을 가지도록 할 수 있다.

     

    4.3 타입 주도 개발

    타입 주도 개발은 테스트 주도 개발에서 테스트를 먼저 작성하고 그 테스트에 맞게 코드를 작성하는 것과 비슷하게 먼저 타입 시그니처를 먼저 작성한 후 해당 타입에 맞게 코드를 작성하는 것을 말한다.

    이러한 타입 주도 개발을 하는 이유는 잘 작성된 타입 시그니처는 설사 처음보는 함수라고 할지라도 그 함수의 목적과 사용법을 타입 시그니처만 보고도 알 수 있기 때문이다. 따라서 기존에 함수의 이름과 함수의 구현 코드만으로 무엇을 하는지, 반환값을 무엇인지, 인자는 무엇인지 추측해야했던 반면 타입 시그니처가 있다면 단번에 인자는 무엇을 받는지 반환값은 무엇을 받는지 알 수 있으며, 목적 또한 금방 알 수 있을 것이다.

     

    우리는 이러한 이점을 챙겨가기 위해 타입 주도 개발을 지향하는 것이 좋은 방향일 것이다.

    댓글

Designed by Tistory.