🔍

TypeScript로 Lens 만들어보기

Date
2023/03/24
Tags
TypeScript
FP
Tutorial
Created by
Lens(Optics)에 대해 간단히 알아보고, TypeScript로 type-safe한 Lens를 만들어보자.

Lens

간단하게, Lens는 “함수형 프로그래밍에서 객체를 immutable하게 다루는, Optics의 일종”이다.
Optics는 Immutability를 지키며 자료구조를 다룰 때 유용하게 사용할 수 있는 함수형 프로그래밍의 개념이다. ‘함수형 프로그래밍의 개념’이라는 표현 자체가 모호하긴 하지만, 대충 ‘함수형 프로그래밍의 패러다임을 따르는, 자료구조를 다루는 데 유용하게 사용할 수 있는 concept’ 정도로 이해하면 되겠다.
Haskell에서는 lens 패키지가 유명하며, 그 외의 Optics의 구현체로는 Scala 라이브러리 Monocle, Kotlin 라이브러리 Arrow의 Optics DSL, Swift 라이브러리 Bow의 Optics 등이 있다. JavaScript의 함수형 라이브러리인 Ramda에도 lens 구현체가 존재한다.
Optics는 크게 두 가지 개념으로 나눠볼 수 있는데, 하나는 Lens이고, 하나는 Prism이다. Lens는 Tuple, Object와 같은 Product types에 사용하기 위한 것을 말하고, Prism은 Union type과 같은 Sum types에 사용하기 위한 것을 말한다.
Lens와 Prism 이외에도 Optics라는 개념 내에는 다양한 하위 개념(Optic)들이 존재하지만, 이 문단의 목적은 ‘Optics의 모든 것을 커버’하는 것이 아닌 ‘Optic 중 하나인 Lens에 대해 간략히 알아보는 것’이므로 다른 개념들에 대해서는 다루지 않는다.
Diagram to visualise how optics relate to each other. https://www.optics.dev/Monocle/docs/optics

In JavaScript

(조금 진부하지만 그래도 예시를 위해…) JavaScript에서 다음과 같은 복잡한 nested object가 있다고 하자.
const employee = { name: 'john', company: { name: 'awesome inc', address: { city: 'london', street: { num: 23, name: 'high street' } } } }
TypeScript
복사
이러한 객체에서 특정 property의 value를 가져오는 건 쉽다.
employee.company.address.street.name // 'high street'
TypeScript
복사
하지만 많이 알려져있듯 객체를 immutable하게 수정하는 것은 꽤나 번거롭다.
const newEmployee = { ...employee, company: { ...employee.company, address: { ...employee.company.address, street: { ...employee.company.address.street, name: 'High street', }, }, }, }
TypeScript
복사
물론 이제 와서야 JavaScript에서는 immutable-jsimmer 같은 걸 쓰면 쉽게 해결될 문제이다. 위와 같이 작성한다 하더라도 TypeScript 등의 정적 타입 체커 덕분에 요즘에는 컴파일 타임에 에러를 찾기 쉬우며, 심지어 immer는 curried producer까지 제공하기 때문에 함수형 프로그래밍에서 사용하기 어렵지도 않다.

Lens의 정의와 구현

Lens는 단순히 Getter와 Setter, 두 개의 함수 쌍으로 볼 수 있다.
Getter get은 전체(Source) S를 받아 부분(View) V를 반환한다. (S → V)
Setter set은 전체(Source)와 부분(View)을 받아 변경된 전체 자료구조(Source)를 반환한다. ((S, V) → S)
이를 반영한 Lens의 타입 정의는 다음과 같다.
interface Lens<S, V> { get: (source: S) => V set: (view: V) => (source: S) => S }
TypeScript
복사
위 코드에서는 set 함수가 V → S → S로 curry되어 있지만, currying을 적용하지 않고 (S, V) → S 형태로 작성해도 상관없다.
interface Lens<S, V> { get: (source: S) => V set: (source: S, view: V) => S }
TypeScript
복사
그리고 get 함수와 set 함수를 받아 Lens<S, V>를 반환하는 함수 lens를 만들어보자. 다음과 같이 두 함수를 인자로 받아 함수 쌍을 반환하는 함수를 쉽게 만들 수 있다.
const lens = <S, V>( get: Lens<S, V>['get'], set: Lens<S, V>['set'], ): Lens<S, V> => ({ get, set, })
TypeScript
복사
이제 Lens를 사용할 수 있게 되었다.
예시를 위해, 다음과 같은 타입 Person과 객체 dogdriip이 있다고 하자.
interface Person { id: string profile: { name: string age: number } } const dogdriip: Person = { id: "1", profile: { name: "Dogdriip", age: 23, } }
TypeScript
복사
다음과 같이 Person 타입 객체의 profile 프로퍼티를 focus(get)하고, 수정(set)하는 Lens를 만들 수 있다.
const profileLens = lens<Person, Person['profile']>( (person) => person.profile, (profile) => (person) => ({ ...person, profile }), )
TypeScript
복사
그리고 나면 다음과 같이 사용할 수 있다.
profileLens.get(dogdriip) // { name: "Dogdriip", age: 23 } const newdriip = profileLens.set({ name: "Newdriip", age: 17 })(dogdriip) profileLens.get(newdriip) // { name: "Newdriip", age: 17 }
TypeScript
복사
profile.age 를 focus하는 Lens도 만들고 사용할 수 있다.
const ageLens = lens<Person, Person['profile']['age']>( (person) => person.profile.age, (age) => (person) => ({ ...person, profile: { ...person.profile, age } }), ) ageLens.get(dogdriip) // 23 const newdriip = ageLens.set(17)(dogdriip) ageLens.get(newdriip) // 17
TypeScript
복사

Lens의 zoom-in: prop 함수의 구현

위에서 Person['profile']을 get/set하는 profileLensPerson['profile']['age']를 get/set하는 ageLens를 간단하게 구현해 보았다. 만약 profile, age가 아닌 다른 property의 수정이 필요하다면, 그 때마다 Lens를 새로 만들어야 할까?
Property 이름을 받아 해당 property를 focus하는 렌즈를 반환하는 prop 함수를 구현해보자.
만드려는 prop 함수는 l: Lens<S, V>property: P를 받아 Lens<S, V[P]> 타입의 Lens를 반환하는 함수다. 이를 기초로 하여 다음과 같이 P → Lens<S, V> → Lens<S, V[P]> 형태로 커링된 함수 Signature를 그려볼 수 있다.
const prop = <V, P>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => { // ... }
TypeScript
복사
그리고 property의 타입 P는 전체 View V의 key이어야 하므로, 다음과 같이 Pkeyof V로 조금 더 좁힐 수 있다.
const prop = <V, P extends keyof V>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => { // ... }
TypeScript
복사
prop이 반환하는 새 Lens인 Lens<S, V[P]>는 어떻게 구현되어야 할까?
일반적인 Lens인 Lens<S, V>에서 Getter는 Source S의 일부인 View V를 반환한다 (S → V). 그렇다면 Lens<S, V[P]> Lens의 Getter는 S를 받아 V[P]를 반환하면(S → V[P]) 되지 않을까? V는 인수로 받아온 l: Lens<S, V>의 Getter로부터 얻을 수 있으니, l.get(source)의 property를 그대로 리턴하면 된다. 다음과 같이 작성할 수 있다.
const prop = <V, P extends keyof V>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => ( lens( (source) => l.get(source)[property], // ... ) )
TypeScript
복사
Lens<S, V[P]>의 Setter는 V[P] → S → S 형태여야 하고, 수정할 View의 property 값 V[P]와 Source S를 받아 해당 property가 수정된 새 Source S를 반환해야 한다.
const prop = <V, P extends keyof V>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => ( lens( (source) => l.get(source)[property], (viewProperty: V[P]) => (source: S) => ( /* ... */ ) ) )
TypeScript
복사
수정할 property 값을 viewProperty: V[P] 인수로, Source를 source: S 인수로 받아왔다. l: Lens<S, V>의 Setter는 수정된 View를 인수로 전달하면 해당 내용이 반영된 Source를 반환하므로, 인수로 받아온 렌즈의 Setter에 property값만 변경된 새 View를 전달하면 된다.
const prop = <V, P extends keyof V>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => lens( (source) => l.get(source)[property], (viewProperty: V[P]) => (source: S) => ( l.set({ ...(l.get(source)), [property]: viewProperty })(source) ) )
TypeScript
복사
이제 prop 함수를 사용해보자. 위에서 사용했던 profileLens를 다시 가져왔다.
const profileLens = lens<Person, Person['profile']>( (person) => person.profile, (profile) => (person) => ({ ...person, profile }), )
TypeScript
복사
Person 타입의 profile 프로퍼티를 focus하는 이 profileLens조차도 prop 함수로 만들어낼 수 있다. 다음과 같이 Source(S)와 View(V)가 S로 같은(즉, 아무 역할도 하지 않는) Lens를 만드는 identity 함수를 먼저 정의하자.
const identity = <S>(): Lens<S, S> => ({ get: (source: S) => source, set: (view: S) => () => view, })
TypeScript
복사
이를 이용해 SV가 모두 Person인 가장 기초가 되는 Lens를 만들고 나면, prop 함수를 이용해 예시에서 사용했던 profileLensageLens, 그리고 새로운 nameLens까지 쉽게 만들 수 있다.
const personLens = identity<Person>() const profileLens = prop<Person, 'profile'>('profile')(personLens) const ageLens = prop<Person['profile'], 'age'>('age')(profileLens) const nameLens = prop<Person['profile'], 'name'>('name')(profileLens)
TypeScript
복사

함수형 프로그래밍에서 사용해보기

여기까지 만들고 나면 일단 함수형 프로그래밍에서 사용할 수 있는 정도가 된다. 맨 처음 보였던 예시를 다시 가져왔다.
const employee = { name: 'john', company: { name: 'awesome inc', address: { city: 'london', street: { num: 23, name: 'high street' } } } }
TypeScript
복사
이번에도 똑같이 employee.company.address.street.name를 수정하고자 한다. 지금까지 작성한 코드를 이용하면 다음과 같이 깊은 property 값을 수정하는 함수를 만들 수 있다.
const setNewStreetName = F.go( L.identity<Employee>(), L.prop('company'), L.prop('address'), L.prop('street'), L.prop('name'), (nameLens) => nameLens.set('High street'), )
TypeScript
복사
- 지금까지 작성한 Lens 관련 코드는 L 모듈 안에 있다고 하자.
- F.go함수는 첫 인수로 value를 받는 일반적인 left-to-right(left-associative, forward) flow function이라고 하자.
const newEmployee = setNewStreetName(employee) expect(newEmployee).toStrictEqual({ name: 'john', company: { name: 'awesome inc', address: { city: 'london', street: { num: 23, name: 'High street', }, }, }, }) // PASS
TypeScript
복사

Lens의 focus에 함수 적용하기: apply 함수의 구현

하지만 아직도 조금 의아할 것이다. 만약 ...street.name property를 다른 값으로 바꾸고 싶거나, 함수를 적용하고 싶다면 해당하는 함수를 그때마다 새로 만들어야 하는가?
‘Property를 수정하는 함수’를 인수로 받아 이를 Lens에 적용하는 함수 apply를 만들어보자.
apply 함수는 View를 수정하는 함수를 받아 이를 Lens의 get 값에 적용하고, View가 변경된 새 Source를 반환한다. 수정 전과 수정 이후의 View가 같은 타입일 이유는 없으므로, 인자로 받을 함수는 (view: V1) => V2 정도가 되겠다. 따라서 apply의 Signature는 대략 ((view: V1) => V2) → Lens<S, V1> → S 정도가 될 것이다.
const apply = <V1, V2 extends V1 = V1>(f: (view: V1) => V2) => <S>(l: Lens<S, V1>) => (source: S): S => ( // ... )
TypeScript
복사
함수 자체의 구현은 어렵지 않다. 인수로 받은 Lens l을 이용해 source: S의 property 값을 get하고, 이에 f 함수를 적용하여 source에 다시 set하면 끝이다. 그대로 코드로 옮기면 다음과 같이 apply 함수를 구현할 수 있다.
const apply = <V1, V2 extends V1 = V1>(f: (view: V1) => V2) => <S>(l: Lens<S, V1>) => (source: S): S => ( l.set(f(l.get(source)))(source) )
TypeScript
복사
맨 위의 예시를 다시 한번 가져와서 employee.company.address.street.name를 수정하는 함수를 작성해보자.
const setNewStreetName = F.go( L.identity<Employee>(), L.prop('company'), L.prop('address'), L.prop('street'), L.prop('name'), L.apply(() => 'High street'), )
TypeScript
복사
아니면 이런 것도 된다.
const capitalize = (s: string): string => s.substring(0, 1).toUpperCase() + s.substring(1) const capitalizeStreetName = F.go( L.identity<Employee>(), L.prop('company'), L.prop('address'), L.prop('street'), L.prop('name'), L.apply(capitalize), ) capitalizeStreetName(employee1) capitalizeStreetName(employee2) // ...
TypeScript
복사

마무리

Optics의 일종인 Lens를 TypeScript로 구현하는 일련의 과정을 작성해 보았다. 막상 과정을 설명하며 작성하려다 보니 Lens를 함수형 프로그래밍스럽게 사용하는 예시에만 집중한 것 같아 조금은 아쉽다. 기회가 된다면 Lens의 합성(composition)이나 Optics의 기초가 되는 Iso부터 차근차근 설명하는 글을 작성해보고 싶다.
아무쪼록 부족한 글이 되어버렸지만, Lens를 몰랐던 프로그래머들과 TypeScript 사용자들에게 도움이 되었으면 좋겠다. 이 글에서 작성한 Lens와 관련 함수를 정리하며 글을 마무리한다.
interface Lens<S, V> { get: (source: S) => V set: (view: V) => (source: S) => S } const lens = <S, V>(get: Lens<S, V>['get'], set: Lens<S, V>['set']): Lens<S, V> => ({ get, set }) const identity = <S>(): Lens<S, S> => ({ get: (source: S) => source, set: (view: S) => () => view, }) const prop = <V, P extends keyof V>(property: P) => <S>(l: Lens<S, V>): Lens<S, V[P]> => lens( (source) => l.get(source)[property], (viewProperty: V[P]) => (source: S) => ( l.set({ ...(l.get(source)), [property]: viewProperty })(source) ) ) const apply = <V1, V2 extends V1 = V1>(f: (view: V1) => V2) => <S>(l: Lens<S, V1>) => (source: S): S => ( l.set(f(l.get(source)))(source) )
TypeScript
복사

References