Merge pull request #12 from inato/exo5-traverse

Exo5 traverse
This commit is contained in:
Vincent François 2022-03-08 11:13:20 +01:00 committed by GitHub
commit 6e15174549
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 335 additions and 0 deletions

View file

@ -2,4 +2,9 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testPathIgnorePatterns: ['/dist/', '/node_modules/'],
globals: {
'ts-jest': {
diagnostics: false,
},
},
};

101
src/exo5/exo5.test.ts Normal file
View file

@ -0,0 +1,101 @@
import { option } from 'fp-ts';
import {
getCountryCurrencyOfOptionalCountryCode,
getValidCountryCodeOfCountryNames,
giveCurrencyOfCountryToUser,
performAsyncComputationInParallel,
performAsyncComputationInSequence,
sequenceOptionArray,
sequenceOptionTask,
} from './exo5';
describe('exo5', () => {
describe('getCountryCurrencyOfOptionalCountryCode', () => {
it('should return a Task<None> if given a None', async () => {
const result = await getCountryCurrencyOfOptionalCountryCode(
option.none,
)();
expect(result).toStrictEqual(option.none);
});
it('should return a Task<Option> with the currency if given a Some', async () => {
const result = await getCountryCurrencyOfOptionalCountryCode(
option.some('FR'),
)();
expect(result).toStrictEqual(option.some('EUR'));
});
});
describe('giveCurrencyOfCountryToUser', () => {
it('should return Some<EUR> if provided string is "France"', async () => {
const result = await giveCurrencyOfCountryToUser('France')();
expect(result).toStrictEqual(option.some('EUR'));
});
it('should return Some<DOLLAR> if provided string is "USA"', async () => {
const result = await giveCurrencyOfCountryToUser('USA')();
expect(result).toStrictEqual(option.some('DOLLAR'));
});
it('should return None if provided string is "Germany"', async () => {
const result = await giveCurrencyOfCountryToUser('Germany')();
expect(result).toStrictEqual(option.none);
});
});
describe('getValidCountryCodeOfCountryNames', () => {
it('should return a Some of the country codes if all the country names are valid', () => {
const result = getValidCountryCodeOfCountryNames(['France', 'Spain']);
expect(result).toStrictEqual(option.some(['FR', 'SP']));
});
it('should return a None if any of the country names is not valid', () => {
const result = getValidCountryCodeOfCountryNames(['France', 'Germany']);
expect(result).toStrictEqual(option.none);
});
});
describe('performAsyncComputationInParallel', () => {
it('should return the same value for each element with the same value', async () => {
const result = await performAsyncComputationInParallel([1, 1, 1])();
expect(result).toStrictEqual([1, 1, 1]);
});
});
describe('performAsyncComputationInSequence', () => {
it('should return an increasing value for each element with the same value', async () => {
const result = await performAsyncComputationInSequence([1, 1, 1])();
expect(result).toStrictEqual([1, 2, 3]);
});
});
describe('sequenceOptionTask', () => {
it('should return a None if called with a None', async () => {
const result = await sequenceOptionTask(option.none)();
expect(result).toStrictEqual(option.none);
});
it('should return a Some if called with a Some', async () => {
const result = await sequenceOptionTask(option.some(async () => 'EUR'))();
expect(result).toStrictEqual(option.some('EUR'));
});
});
describe('sequenceOptionArray', () => {
it('should return a None if one of the option in the array is None', () => {
const result = sequenceOptionArray([option.none, option.some('FR')]);
expect(result).toStrictEqual(option.none);
});
it('should return a Some if all the options in the arrat are Some', () => {
const result = sequenceOptionArray([
option.some('FR'),
option.some('SP'),
]);
expect(result).toStrictEqual(option.some(['FR', 'SP']));
});
});
});

228
src/exo5/exo5.ts Normal file
View file

@ -0,0 +1,228 @@
// `fp-ts` training Exercice 5
// Managing nested effectful data with `traverse`
import { option, readonlyRecord, task } from 'fp-ts';
import { pipe } from 'fp-ts/lib/function';
import { Option } from 'fp-ts/lib/Option';
import { ReadonlyRecord } from 'fp-ts/lib/ReadonlyRecord';
import { Task } from 'fp-ts/lib/Task';
import { sleep, unimplemented, unimplementedAsync } from '../utils';
// When using many different Functors in a complex application, we can easily
// get to a point when we have many nested types that we would like to 'merge',
// like `Task<Option<Task<A>>>` or `Either<E,ReadonlyArray<Either<E,A>>>`
// It would be nice to have a way to 'move up' the similar types in order to
// chain them, like merging the `Task` to have a `Task<Option<A>>` or the
// `Either` to have a `Either<E,ReadonlyArray<A>>`
//
// That's precisely the concept of `traverse`. It will allow us to transform
// a `Option<Task<A>>` to a `Task<Option<A>>` so we can chain it with another
// `Task` for example, or to transform a `ReadonlyArray<Either<E,A>>` to a
// `Either<E,ReadonlyArray<A>>`
///////////////////////////////////////////////////////////////////////////////
// SETUP //
///////////////////////////////////////////////////////////////////////////////
// Let's consider a small range of countries (here, France, Spain and the USA)
// with a mapping from their name to their code:
type CountryCode = 'FR' | 'SP' | 'US';
export const countryNameToCountryCode: ReadonlyRecord<string, CountryCode> = {
France: 'FR',
Spain: 'SP',
USA: 'US',
};
// Let's simulate the call to an api which would return the currency when
// providing a country code. For the sake of simplicity, let's consider that it
// cannot fail.
type Currency = 'EUR' | 'DOLLAR';
export const getCountryCurrency: (countryCode: CountryCode) => Task<Currency> =
(countryCode: CountryCode): Task<Currency> =>
async () => {
if (countryCode === 'US') {
return 'DOLLAR';
}
return 'EUR';
};
// Let's simulate a way for the user to provide a country name.
// Let's consider that it cannot fail and let's add the possibility to set
// the user's response as a parameter for easier testing.
export const getCountryNameFromUser: (countryName: string) => Task<string> = (
countryName: string,
) => task.of(countryName);
// Here's a function to retrieve the countryCode from a country name if it is
// matching a country we support. This method returns an `Option` as we cannot
// return anything if the given string is not matching a country name we know
export const getCountryCode: (countryName: string) => Option<CountryCode> = (
countryName: string,
) => readonlyRecord.lookup(countryName)(countryNameToCountryCode);
///////////////////////////////////////////////////////////////////////////////
// TRAVERSING OPTIONS //
///////////////////////////////////////////////////////////////////////////////
// With all these functions, we can simulate a program that would ask for a
// country name and return its currency if it knows the country.
// A naive implementation would be mapping on each `Task` and `Option` to call
// the correct method:
export const naiveGiveCurrencyOfCountryToUser = (
countryNameFromUserMock: string,
) =>
pipe(
getCountryNameFromUser(countryNameFromUserMock),
task.map(getCountryCode),
task.map(option.map(getCountryCurrency)),
);
// The result type of this method is: `Task<Option<Task<Currency>>>`
// Not ideal, right? We would need to await the first `Task`, then check if it's
// `Some` to get the `Task` inside and finally await the `Task` to retrieve the
// currency.
// Let's do better than that!
// First we need a way to transform our `Option<Task<Currency>>` to
// `Task<Option<Currency>>`
// That's precisely what traverse is about.
// Use `option.traverse` to implement `getCountryCurrencyOfOptionalCountryCode`
// below. This function takes an `Option<CountryCode>`, should apply
// `getCountryCurrency` to the `CountryCode` and make it so that the result
// is `Task<Option<Currency>>`
//
// HINT: `option.traverse` asks for an Applicative as the first parameter. You
// can find it for `Task` in `task.ApplicativePar`
export const getCountryCurrencyOfOptionalCountryCode: (
optionalCountryCode: Option<CountryCode>,
) => Task<Option<Currency>> = unimplementedAsync;
// Let's now use this function in our naive implementation's pipe to how it
// improves it.
// Implement `giveCurrencyOfCountryToUser` below so that it returns a
// `Task<Option<Currency>>`
//
// HINT: You should be able to copy the pipe from naiveGiveCurrencyOfCountryToUser
// and make only few updates of it
export const giveCurrencyOfCountryToUser: (
countryNameFromUserMock: string,
) => Task<Option<Currency>> = unimplementedAsync;
// BONUS: We don't necessarily need `traverse` to do this. Try implementing
// `giveCurrencyOfCountryToUser` by lifting some of the functions' results to
// `TaskOption`
///////////////////////////////////////////////////////////////////////////////
// TRAVERSING ARRAYS //
///////////////////////////////////////////////////////////////////////////////
// Let's say we want to ask multiple countries to the user. We'll have an array
// of country names as `string` and we want to retrieve the country code of each.
// Looks pretty easy:
export const getCountryCodeOfCountryNames = (
countryNames: ReadonlyArray<string>,
) => countryNames.map(getCountryCode);
// As expected, we end up with a `ReadonlyArray<Option<CountryCode>>`. We know for
// each item of the array if we have been able to find the corresponding country
// code or not.
// While this can be useful, you need to handle the option anytime you want to
// perform any operation on each country code (let's say you want get the currency
// of each)
// It would be easier to 'merge' all the options into one and have a `Some` only if
// all the country codes are `Some` and a `None` if at least one is `None`.
// Doing this allows you to stop the process if you have a `None` to tell the user
// that some countries are not valid or move on with a `ReadonlyArray<CountryCode>>`
// if all are valid.
// Typewise, it means going from `ReadonlyArray<Option<CountryCode>>` to
// `Option<ReadonlyArray<CountryCode>>`
// This is what traversing array is about.
// Let's write a method that gets the country code for each element of an array
// of country names and returns an option of an array of country codes.
//
// HINT: while `readonlyArray.traverse` exists, you have a shortcut in the `option`
// module: `option.traverseArray`
export const getValidCountryCodeOfCountryNames: (
countryNames: ReadonlyArray<string>,
) => Option<ReadonlyArray<CountryCode>> = unimplemented;
///////////////////////////////////////////////////////////////////////////////
// TRAVERSING ARRAYS ASYNCHRONOUSLY //
///////////////////////////////////////////////////////////////////////////////
// We've seen how to traverse an `array` of `option`s but this is not something
// specific to `option`. We can traverse an `array` of any applicative functor,
// like `either` or `task` for example.
// When dealing with functors that perform asynchronous side effects, like
//`task`, comes the question of parallelization. Do we want to run the
// computation on each item of the array in parallel or one after the other?
// Both are equally feasible with fp-ts, let's discover it!
// Let's simulate a method that reads a number in a database, does some async
// computation with it, replaces this number in the database by the result of
// the computation and returns it
const createSimulatedAsyncMethod = (): ((toAdd: number) => Task<number>) => {
let number = 0;
return (toAdd: number) => async () => {
const currentValue = number;
await sleep(100);
number = currentValue + toAdd;
return number;
};
};
// Write a method to traverse an array by running the method
// `simulatedAsyncMethodForParallel: (toAdd: number) => Task<number>`
// defined below on each item in parallel.
//
// HINT: as was the case for `option`, you have a few helpers in the `task`
// module to traverse arrays
export const simulatedAsyncMethodForParallel = createSimulatedAsyncMethod();
export const performAsyncComputationInParallel: (
numbers: ReadonlyArray<number>,
) => Task<ReadonlyArray<number>> = unimplementedAsync;
// Write a method to traverse an array by running the method
// `simulatedAsyncMethodForSequence: (toAdd: number) => Task<number>`
// defined below on each item in sequence.
//
// HINT: as was the case for `option`, you have a few helpers in the `task`
// module to traverse arrays
export const simulatedAsyncMethodForSequence = createSimulatedAsyncMethod();
export const performAsyncComputationInSequence: (
numbers: ReadonlyArray<number>,
) => Task<ReadonlyArray<number>> = unimplementedAsync;
///////////////////////////////////////////////////////////////////////////////
// SEQUENCE //
///////////////////////////////////////////////////////////////////////////////
// `traverse` is nice when you need to get the value inside a container (let's
// say `Option`), apply a method to it that return another container type (let's
// say `Task`) and 'invert' the container (to get a `Task<Option>` instead of a
// `Option<Task>` in our example)
// Sometimes, you just have two nested containers that you want to 'invert'. It
// can be because both order of container are meaningful (like `Either<Option>`
// and `Option<Either>`) of because you got them from an external api, as
// examples.
// In that case, what you need is `sequence`, which you can find in the modules
// that have `traverse`.
//
// Use the `sequence` methods from the `option` module to implement the two
// functions below
export const sequenceOptionTask: (
optionOfTask: Option<Task<Currency>>,
) => Task<Option<Currency>> = unimplementedAsync;
export const sequenceOptionArray: (
arrayOfOptions: ReadonlyArray<Option<CountryCode>>,
) => Option<ReadonlyArray<CountryCode>> = unimplemented;
// BONUS: try using these two functions in the exercices 'TRAVERSING OPTIONS'
// and 'TRAVERSING ARRAYS' above

1
src/exo5/index.ts Normal file
View file

@ -0,0 +1 @@
export * from './exo5';