해당 게시글은 mostly-adequate-guide 의 시리즈들을 번역했습니다.
해당 게시글의 저작권은 mostly-adequate-guide 의 저작권인 Attribution-ShareAlike 4.0 International 을 준수합니다.
🤖 AI가 요약한 글이에요!
이 장에서는 포인티드 펑터(Pointed Functor) 와 모나드(Monad) 의 개념을 소개합니다.
포인티드 펑터는of
메서드를 통해 값을 기본 컨텍스트에 넣는 기능을 제공합니다.
모나드는 포인티드 펑터이면서 중첩된 컨텍스트를 단일 레이어로 평탄화하는join
메서드를 가진 타입입니다.
map
과join
을 결합한chain
(또는flatMap
,bind
) 함수를 통해 중첩된 효과(effect)를 순차적으로 처리하고 함수형 방식으로 변수를 할당하는 방법을 보여줍니다.
모나드는 오류 처리, 비동기 작업, 상태 관리 등 복잡한 제어 흐름을 순수하고 선언적인 방식으로 다룰 수 있게 해줍니다.
Chapter 09: 모나딕 양파 (Monadic Onions)
포인티드 펑터 팩토리
더 진행하기 전에 고백할 것이 있습니다: 우리가 각 타입에 배치한 of
메서드에 대해 완전히 솔직하지 않았습니다. 알고 보니, 그것은 new
키워드를 피하기 위해 있는 것이 아니라, 값을 소위 기본 최소 컨텍스트(default minimal context) 에 넣기 위한 것입니다. 네, of
는 실제로 생성자를 대체하는 것이 아닙니다 - 그것은 우리가 포인티드(Pointed) 라고 부르는 중요한 인터페이스의 일부입니다.
포인티드 펑터(Pointed functor) 는
of
메서드를 가진 펑터입니다.
여기서 중요한 것은 어떤 값이든 우리 타입에 넣고 map
을 시작할 수 있는 능력입니다.
IO.of("tetris").map(concat(" master"));
// IO('tetris master')
Maybe.of(1336).map(add(1));
// Maybe(1337)
Task.of([{ id: 2 }, { id: 3 }]).map(map(prop("id")));
// Task([2,3])
Either.of("The past, present and future walk into a bar...").map(
concat("it was tense.")
);
// Right('The past, present and future walk into a bar...it was tense.')
기억하신다면, IO
와 Task
의 생성자는 인수로 함수를 기대하지만, Maybe
와 Either
는 그렇지 않습니다. 이 인터페이스의 동기는 생성자의 복잡성과 특정 요구 사항 없이 값을 펑터에 넣는 공통적이고 일관된 방법입니다. "기본 최소 컨텍스트"라는 용어는 정밀성이 부족하지만, 어떤 값이든 우리 타입으로 끌어올리고(lift) 어떤 펑터든 기대되는 동작으로 평소처럼 map
을 사용하고 싶다는 아이디어를 잘 포착합니다.
이 시점에서 제가 해야 할 중요한 수정 사항 하나는, 말장난이지만, Left.of
는 말이 안 된다는 것입니다. 각 펑터는 값을 그 안에 넣는 한 가지 방법을 가져야 하며, Either
의 경우 그것은 new Right(x)
입니다. 우리는 Right
를 사용하여 of
를 정의합니다. 왜냐하면 우리 타입이 map
을 할 수 있다면, map
을 해야 하기 때문입니다. 위의 예시들을 보면, of
가 일반적으로 어떻게 작동할지에 대한 직관을 가져야 하며, Left
는 그 틀을 깹니다.
pure
, point
, unit
, return
과 같은 함수에 대해 들어보셨을 수도 있습니다. 이것들은 우리의 of
메서드, 즉 미스터리한 국제 함수에 대한 다양한 별명입니다. of
는 모나드를 사용하기 시작할 때 중요해질 것입니다. 왜냐하면 우리가 보게 될 것처럼, 값을 타입 안으로 다시 수동으로 넣는 것은 우리의 책임이기 때문입니다.
new
키워드를 피하기 위해 여러 표준 자바스크립트 트릭이나 라이브러리가 있으므로, 그것들을 사용하고 이제부터는 책임감 있는 어른처럼 of
를 사용합시다. folktale
, ramda
또는 fantasy-land
의 펑터 인스턴스를 사용하는 것을 추천합니다. 이들은 올바른 of
메서드뿐만 아니라 new
에 의존하지 않는 멋진 생성자를 제공하기 때문입니다.
은유 섞기
모나드는 양파랑 비슷해요
아시다시피, 우주 부리토 외에도 (소문을 들으셨다면), 모나드는 양파와 같습니다. 흔한 상황으로 설명해 보겠습니다:
const fs = require("fs");
// readFile :: String -> IO String
const readFile = (filename) => new IO(() => fs.readFileSync(filename, "utf-8"));
// print :: String -> IO String
const print = (x) =>
new IO(() => {
console.log(x);
return x;
});
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
cat(".git/config");
// IO(IO('[core]\nrepositoryformatversion = 0\n'))
여기서 우리가 가진 것은 print
가 map
도중에 두 번째 IO
를 도입했기 때문에 다른 IO
안에 갇힌 IO
입니다. 우리의 문자열로 계속 작업하려면 map(map(f))
를 해야 하고, 효과를 관찰하려면 unsafePerformIO().unsafePerformIO()
를 해야 합니다.
// cat :: String -> IO (IO String)
const cat = compose(map(print), readFile);
// catFirstChar :: String -> IO (IO String)
const catFirstChar = compose(map(map(head)), cat);
catFirstChar(".git/config");
// IO(IO('['))
애플리케이션에 두 가지 효과가 포장되어 준비된 것을 보는 것은 좋지만, 마치 두 개의 방호복을 입고 작업하는 것 같고 불편하게 어색한 API로 끝나게 됩니다. 다른 상황을 살펴봅시다:
// safeProp :: Key -> {Key: a} -> Maybe a
const safeProp = curry((x, obj) => Maybe.of(obj[x]));
// safeHead :: [a] -> Maybe a
const safeHead = safeProp(0);
// firstAddressStreet :: User -> Maybe (Maybe (Maybe Street))
const firstAddressStreet = compose(
map(map(safeProp("street"))),
map(safeHead),
safeProp("addresses")
);
firstAddressStreet({
addresses: [{ street: { name: "Mulburry", number: 8402 }, postcode: "WC2N" }],
});
// Maybe(Maybe(Maybe({name: 'Mulburry', number: 8402})))
다시 한번, 이 중첩된 펑터 상황을 봅니다. 함수에 세 가지 가능한 실패 지점이 있다는 것을 보는 것은 깔끔하지만, 호출자가 값에 접근하기 위해 세 번 map
을 할 것으로 기대하는 것은 약간 건방진 일입니다 - 우리는 방금 만났을 뿐입니다. 이 패턴은 계속해서 나타날 것이며, 이것이 우리가 밤하늘에 강력한 모나드 상징을 비춰야 할 주요 상황입니다.
모나드는 양파와 같다고 말했습니다. 왜냐하면 중첩된 펑터의 각 층을 map
으로 벗겨내어 내부 값에 도달할 때 눈물이 차오르기 때문입니다. 우리는 눈물을 닦고, 심호흡을 하고, join
이라는 메서드를 사용할 수 있습니다.
const mmo = Maybe.of(Maybe.of("nunchucks"));
// Maybe(Maybe('nunchucks'))
mmo.join();
// Maybe('nunchucks')
const ioio = IO.of(IO.of("pizza"));
// IO(IO('pizza'))
ioio.join();
// IO('pizza')
const ttt = Task.of(Task.of(Task.of("sewers")));
// Task(Task(Task('sewers')));
ttt.join();
// Task(Task('sewers'))
같은 타입의 두 레이어가 있다면, join
으로 합칠 수 있습니다. 이렇게 합치는 능력, 이 펑터 결합이 모나드를 모나드로 만드는 것입니다. 좀 더 정확한 것으로 전체 정의에 조금씩 다가가 봅시다:
모나드는 평탄화할 수 있는 포인티드 펑터입니다.
join
메서드를 정의하고, of
메서드를 가지며, 몇 가지 법칙을 따르는 모든 펑터는 모나드입니다. join
을 정의하는 것은 그리 어렵지 않으므로 Maybe
에 대해 그렇게 해봅시다:
Maybe.prototype.join = function join() {
return this.isNothing() ? Maybe.of(null) : this.$value;
};
거기, 자궁에서 쌍둥이를 소비하는 것만큼 간단합니다. Maybe(Maybe(x))
가 있다면 .$value
는 불필요한 추가 레이어를 제거하고 거기서부터 안전하게 map
할 수 있습니다. 그렇지 않으면, 애초에 아무것도 map
되지 않았을 것이므로 하나의 Maybe
만 가질 것입니다.
이제 join
메서드가 있으니, firstAddressStreet
예제에 마법의 모나드 가루를 뿌리고 그것이 작동하는 것을 봅시다:
// join :: Monad m => m (m a) -> m a
const join = (mma) => mma.join();
// firstAddressStreet :: User -> Maybe Street
const firstAddressStreet = compose(
join, // Maybe(Maybe(Maybe Street)) -> Maybe(Maybe Street)
map(safeProp("street")), // Maybe(Maybe Address) -> Maybe(Maybe(Maybe Street))
join, // Maybe(Maybe Address) -> Maybe Address
map(safeHead), // Maybe [Address] -> Maybe(Maybe Address)
safeProp("addresses") // User -> Maybe [Address]
);
firstAddressStreet({
addresses: [{ street: { name: "Mulburry", number: 8402 }, postcode: "WC2N" }],
});
// Maybe({name: 'Mulburry', number: 8402})
중첩된 Maybe
가 발생할 때마다 join
을 추가하여 통제 불능 상태가 되지 않도록 했습니다. IO
에 대해서도 동일하게 수행하여 감을 잡아봅시다.
IO.prototype.join = function () {
const $ = this;
return new IO(() => $.unsafePerformIO().unsafePerformIO());
};
우리는 단순히 두 레이어의 IO를 순차적으로 실행하는 것을 묶습니다: 외부 다음 내부. 명심하세요, 우리는 순수성을 버린 것이 아니라, 단지 과도한 두 겹의 수축 포장을 열기 쉬운 하나의 패키지로 재포장했을 뿐입니다.
// log :: a -> IO a
const log = (x) =>
new IO(() => {
console.log(x);
return x;
});
// setStyle :: Selector -> CSSProps -> IO DOM
const setStyle = curry((sel, props) => new IO(() => jQuery(sel).css(props)));
// getItem :: String -> IO String
const getItem = (key) => new IO(() => localStorage.getItem(key));
// applyPreferences :: String -> IO DOM
const applyPreferences = compose(
join, // IO(IO DOM) -> IO DOM
map(setStyle("#main")), // IO Object -> IO(IO DOM)
join, // IO(IO Object) -> IO Object
map(log), // IO String -> IO(IO Object)
map(JSON.parse), // IO String -> IO String (parsed)
getItem // String -> IO String
);
applyPreferences("preferences").unsafePerformIO();
// Object {backgroundColor: "green"}
// <div style="background-color: 'green'"/>
getItem
은 IO String
을 반환하므로 map
을 사용하여 구문 분석합니다. log
와 setStyle
모두 자체적으로 IO
를 반환하므로 중첩을 제어하기 위해 join
해야 합니다.
내 체인이 내 가슴을 친다 (My Chain Hits My Chest)
체인
패턴을 눈치채셨을 수도 있습니다. 우리는 종종 map
직후에 join
을 호출하게 됩니다. 이것을 chain
이라는 함수로 추상화해 봅시다.
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = curry((f, m) => m.map(f).join());
// 또는
// chain :: Monad m => (a -> m b) -> m a -> m b
const chain = (f) => compose(join, map(f));
이 map/join 콤보를 단일 함수로 묶을 것입니다. 이전에 모나드에 대해 읽어보셨다면, chain
이 >>=
(바인드라고 발음) 또는 flatMap
이라고 불리는 것을 보셨을 수도 있습니다. 이들은 모두 같은 개념에 대한 별칭입니다. 개인적으로 flatMap
이 가장 정확한 이름이라고 생각하지만, JS에서 널리 받아들여지는 이름인 chain
을 고수할 것입니다. 위의 두 예제를 chain
으로 리팩토링해 봅시다:
// map/join
const firstAddressStreet = compose(
join,
map(safeProp("street")),
join,
map(safeHead),
safeProp("addresses")
);
// chain
const firstAddressStreet = compose(
chain(safeProp("street")), // Maybe Address -> Maybe Street
chain(safeHead), // Maybe [Address] -> Maybe Address
safeProp("addresses") // User -> Maybe [Address]
);
// map/join
const applyPreferences = compose(
join,
map(setStyle("#main")),
join,
map(log),
map(JSON.parse),
getItem
);
// chain
const applyPreferences = compose(
chain(setStyle("#main")), // Object -> IO DOM
chain(log), // String -> IO Object
map(JSON.parse), // String -> Object
getItem // String -> IO String
);
새로운 chain
함수로 모든 map/join
을 교체하여 약간 정리했습니다. 깔끔함은 좋지만, chain
에는 눈에 보이는 것 이상의 것이 있습니다 - 진공청소기라기보다는 토네이도에 가깝습니다. chain
은 효과를 손쉽게 중첩시키기 때문에, 순수하게 함수적인 방식으로 순서(sequence) 와 변수 할당(variable assignment) 을 모두 포착할 수 있습니다.
// getJSON :: Url -> Params -> Task JSON
getJSON("/authenticate", { username: "stale", password: "crackers" }).chain(
(user) => getJSON("/friends", { user_id: user.id })
);
// Task([{name: 'Seimith', id: 14}, {name: 'Ric', id: 39}]);
// querySelector :: Selector -> IO DOM
querySelector("input.username").chain(({ value: uname }) =>
querySelector("input.email").chain(({ value: email }) =>
IO.of(`Welcome ${uname} prepare for spam at ${email}`)
)
);
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');
Maybe.of(3).chain((three) => Maybe.of(2).map(add(three)));
// Maybe(5);
Maybe.of(null).chain(safeProp("address")).chain(safeProp("street"));
// Maybe(null);
어쨌든, 위의 예시들로 넘어가 봅시다. 첫 번째 예시에서는 두 개의 Task
가 비동기 액션의 순서로 연결된 것을 봅니다 - 먼저 user
를 검색한 다음, 해당 사용자의 ID로 친구를 찾습니다. Task(Task([Friend]))
상황을 피하기 위해 chain
을 사용합니다.
다음으로, querySelector
를 사용하여 몇 가지 다른 입력을 찾고 환영 메시지를 만듭니다. 가장 안쪽 함수에서 uname
과 email
모두에 접근할 수 있다는 점에 주목하세요 - 이것이 함수형 변수 할당의 정수입니다. IO
가 친절하게 값을 빌려주기 때문에, 우리는 그것을 발견한 대로 다시 넣을 책임이 있습니다 - 우리는 그 신뢰(그리고 우리 프로그램)를 깨뜨리고 싶지 않습니다. IO.of
는 이 작업에 완벽한 도구이며, 이것이 포인티드가 모나드 인터페이스의 중요한 전제 조건인 이유입니다. 그러나 map
을 선택할 수도 있습니다. 왜냐하면 그것도 올바른 타입을 반환할 것이기 때문입니다:
querySelector("input.username").chain(({ value: uname }) =>
querySelector("input.email").map(
({ value: email }) => `Welcome ${uname} prepare for spam at ${email}`
)
);
// IO('Welcome Olivia prepare for spam at olivia@tremorcontrol.net');
마지막으로, Maybe
를 사용하는 두 가지 예시가 있습니다. chain
은 내부적으로 매핑하고 있으므로, 어떤 값이 null
이면 계산을 즉시 중단합니다.
이 예시들이 처음에는 이해하기 어렵더라도 걱정하지 마세요. 가지고 놀아보세요. 막대기로 찔러보세요. 산산조각 내고 다시 조립해보세요. "일반적인" 값을 반환할 때는 map
을 사용하고 다른 펑터를 반환할 때는 chain
을 사용해야 함을 기억하세요. 다음 장에서는 어플리커티브(Applicatives)에 접근하고 이러한 종류의 표현식을 더 멋지고 가독성 높게 만드는 멋진 트릭을 볼 것입니다.
참고로, 이것은 두 개의 다른 중첩된 타입에서는 작동하지 않습니다. 펑터 합성과 나중에 나올 모나드 트랜스포머가 그 상황에서 우리를 도울 수 있습니다.
권력 여행 (Power Trip)
컨테이너 스타일 프로그래밍은 때때로 혼란스러울 수 있습니다. 우리는 때때로 값이 얼마나 많은 컨테이너 깊이에 있는지 또는 map
이나 chain
이 필요한지 이해하는 데 어려움을 겪습니다 (곧 더 많은 컨테이너 메서드를 보게 될 것입니다). inspect
구현과 같은 트릭으로 디버깅을 크게 개선할 수 있으며, 우리가 던지는 어떤 효과든 처리할 수 있는 "스택"을 만드는 방법을 배울 것이지만, 그것이 번거로움을 감수할 가치가 있는지 의문이 들 때가 있습니다.
이 방식으로 프로그래밍하는 힘을 보여주기 위해 잠시 불타는 모나딕 검을 휘두르고 싶습니다.
파일을 읽은 다음 즉시 업로드해 봅시다:
// readFile :: Filename -> Either String (Task Error String)
// httpPost :: String -> String -> Task Error JSON
// upload :: Filename -> Either String (Task Error JSON)
const upload = compose(map(chain(httpPost("/uploads"))), readFile);
여기서 우리는 코드를 여러 번 분기하고 있습니다. 타입 시그니처를 보면 3가지 오류로부터 보호하고 있음을 알 수 있습니다 - readFile
은 Either
를 사용하여 입력을 검증하고 (아마도 파일 이름이 있는지 확인), readFile
은 Task
의 첫 번째 타입 매개변수에 표현된 것처럼 파일에 접근할 때 오류가 발생할 수 있으며, 업로드는 httpPost
의 Error
로 표현된 어떤 이유로든 실패할 수 있습니다. 우리는 chain
을 사용하여 두 개의 중첩되고 순차적인 비동기 액션을 아무렇지도 않게 처리합니다.
이 모든 것이 하나의 선형적인 왼쪽에서 오른쪽으로의 흐름으로 달성됩니다. 이 모든 것은 순수하고 선언적입니다. 등식 추론과 신뢰할 수 있는 속성을 가지고 있습니다. 불필요하고 혼란스러운 변수 이름을 추가하도록 강요받지 않습니다. 우리의 upload
함수는 특정 일회성 API가 아닌 일반적인 인터페이스에 대해 작성되었습니다. 젠장, 한 줄짜리입니다.
대조적으로, 이것을 해내는 표준적인 명령형 방식을 살펴봅시다:
// upload :: Filename -> (String -> a) -> Void
const upload = (filename, callback) => {
if (!filename) {
throw new Error("You need a filename!");
} else {
readFile(filename, (errF, contents) => {
if (errF) throw errF;
httpPost("/uploads", contents, (errH, json) => {
if (errH) throw errH;
callback(json);
});
});
}
};
글쎄, 이건 악마의 산수가 아닌가요. 우리는 변덕스러운 광기의 미로를 통해 핀볼처럼 튕겨 다닙니다. 만약 도중에 변수를 변경하는 일반적인 앱이었다면 어떨지 상상해보세요! 우리는 정말로 타르 구덩이에 빠졌을 것입니다.
이론
우리가 살펴볼 첫 번째 법칙은 결합 법칙(associativity)이지만, 아마도 여러분이 익숙한 방식은 아닐 것입니다.
// associativity
compose(join, map(join)) === compose(join, join);
이 법칙들은 모나드의 중첩된 특성에 관한 것이므로, 결합 법칙은 내부 또는 외부 타입을 먼저 결합하여 동일한 결과를 얻는 데 중점을 둡니다. 그림이 더 도움이 될 수 있습니다:
Monad Associativity Diagram
왼쪽 상단에서 아래로 이동하면서, M(M(M a))
의 외부 두 M
을 먼저 join
한 다음 다른 join
으로 원하는 M a
로 이동할 수 있습니다. 또는, 후드를 열고 map(join)
으로 내부 두 M
을 평탄화할 수 있습니다. 내부 또는 외부 M
을 먼저 결합하든 상관없이 동일한 M a
로 끝나며, 이것이 결합 법칙의 전부입니다. map(join) != join
이라는 점은 주목할 가치가 있습니다. 중간 단계는 값이 다를 수 있지만, 마지막 join
의 최종 결과는 동일합니다.
두 번째 법칙도 비슷합니다:
// identity for all (M a)
(compose(join, of) === compose(join, map(of))) === id;
이것은 어떤 모나드 M
에 대해서든, of
와 join
은 id
와 같다는 것을 말합니다. 우리는 또한 map(of)
를 사용하여 안쪽에서부터 공격할 수도 있습니다. 이것을 시각화했을 때 삼각형 모양을 만들기 때문에 "삼각형 항등(triangle identity)"이라고 부릅니다:
Monad Identity Diagram
왼쪽 상단에서 오른쪽으로 이동하면, of
가 실제로 우리의 M a
를 다른 M
컨테이너에 넣는 것을 볼 수 있습니다. 그런 다음 아래로 이동하여 join
하면, 처음에 id
를 호출한 것과 동일한 결과를 얻습니다. 오른쪽에서 왼쪽으로 이동하면, map
으로 덮개 아래로 몰래 들어가 일반 a
의 of
를 호출하더라도 여전히 M (M a)
로 끝나고 join
하면 처음으로 돌아갑니다.
제가 방금 of
라고 썼지만, 이것은 우리가 사용하고 있는 어떤 모나드든 특정 M.of
여야 한다는 점을 언급해야 합니다.
이제, 저는 이 법칙들, 항등과 결합 법칙을 전에 어디선가 본 적이 있습니다... 잠깐만요, 생각 중입니다... 네 물론이죠! 이것들은 카테고리에 대한 법칙입니다. 하지만 그렇다면 정의를 완성하기 위해 합성 함수가 필요하다는 의미입니다. 보십시오:
// mcompose :: Monad m => (b -> m c) -> (a -> m b) -> a -> m c
const mcompose = (f, g) => compose(chain(f), g);
// left identity
mcompose(M.of, f) === f;
// right identity
mcompose(f, M.of) === f;
// associativity
mcompose(mcompose(f, g), h) === mcompose(f, mcompose(g, h));
결국 카테고리 법칙입니다. 모나드는 모든 객체가 모나드이고 사상(morphism)이 연결된 함수인 "클라이슬리 카테고리(Kleisli category)"라는 카테고리를 형성합니다. 퍼즐 조각이 어떻게 맞춰지는지에 대한 많은 설명 없이 카테고리 이론의 일부를 조금씩 보여주며 여러분을 놀리려는 의도는 없습니다. 의도는 관련성을 보여주고 우리가 매일 사용할 수 있는 실용적인 속성에 초점을 맞추면서 약간의 흥미를 유발할 만큼 표면을 긁는 것입니다.
요약
모나드는 중첩된 계산 속으로 파고들 수 있게 해줍니다. 우리는 변수를 할당하고, 순차적 효과를 실행하고, 비동기 작업을 수행할 수 있습니다. 이 모든 것을 운명의 피라미드에 벽돌 하나 놓지 않고도 할 수 있습니다. 값이 같은 타입의 여러 레이어에 갇힌 자신을 발견했을 때 구조하러 옵니다. 믿음직한 조수 "포인티드"의 도움으로, 모나드는 우리에게 포장되지 않은 값을 빌려줄 수 있고 우리가 끝났을 때 다시 넣을 수 있다는 것을 압니다.
네, 모나드는 매우 강력하지만, 여전히 몇 가지 추가 컨테이너 함수가 필요합니다. 예를 들어, API 호출 목록을 한 번에 실행한 다음 결과를 수집하고 싶다면 어떨까요? 모나드로 이 작업을 수행할 수 있지만, 다음 호출을 하기 전에 각 호출이 완료될 때까지 기다려야 합니다. 여러 유효성 검사를 결합하는 것은 어떨까요? 오류 목록을 수집하기 위해 계속 유효성 검사를 하고 싶지만, 모나드는 첫 번째 Left
가 등장하면 쇼를 중단할 것입니다.
다음 장에서는 어플리커티브 펑터가 컨테이너 세계에 어떻게 들어맞는지, 그리고 많은 경우에 왜 모나드보다 선호하는지 알아볼 것입니다.
연습 문제
다음과 같은 User 객체를 고려합니다:
const user = {
id: 1,
name: "Albert",
address: {
street: {
number: 22,
name: "Walnut St",
},
},
};
이제 다음 항목들을 고려합니다:
// getFile :: IO String
const getFile = IO.of("/home/mostly-adequate/ch09.md");
// pureLog :: String -> IO ()
const pureLog = (str) => new IO(() => console.log(str));
이 연습 문제에서는 다음과 같은 시그니처를 가진 헬퍼를 고려합니다:
// validateEmail :: Email -> Either String Email
// addToMailingList :: Email -> IO([Email])
// emailBlast :: [Email] -> IO ()