TypeScript has a relatively good type system that lets the developer validate architectural decisions by describing them in the integrated type language. However, taking full advantage requires maintaining referential transparency. This means avoiding mutations and constructing new immutable values with functional programming tools.
This guide attemps to explaing basics of working with a code base written in this manner. The guide covers basics of type variables, io-ts and fp-ts data structures.
Consider the following tiny application that performs a dice roll with a single six-sided die. The code is quite compact and there isn't much room for type signatures.
function diceMain1() {
console.log('rolled: ' + (1 + Math.floor(Math.random() * 6)));
}
Breaking the code into smaller independent parts will let us add more type signatures. By moving parts of the code away from it's original context also increase the need for static typing since the individual parts might otherwise become incompatible with each other.
Applications typically consist of some pure functions and some impure procedures. The pure functions work deterministicly producing the same output for the same inputs, and do not have any side-effects such as logging or networking. The impure procedures are free to be nondeterministic and can do any effects.
Pure functions are in general easier to work with since they don't depend on order
or amount of invocations. For example, you can safely cache the return value of a pure
function, or you can safely remove the cache and call it several times. Let's start
refactoring diceMain1
by defining some primitive types and extracting parts that
can be easily turned into pure functions.
// number x, 0 <= x < 1
type Ratio = number;
// amount of sides on a die
type Sides = number;
// a number printed on a die
type Roll = number;
// text to be displayed as part of the user interface
type Printable = string;
type RollFromRatio = (s: Sides) => (r: Ratio) => Roll;
type PrintableFromRoll = (r: Roll) => Printable;
const rollFromRatio: RollFromRatio = (sides) => (ratio) => 1 + Math.floor(ratio * sides);
const printableFromRoll: PrintableFromRoll = (roll) => `rolled: ${roll}`;
We can then proceed by breaking the remaining code into several impure procedures that use the the pure library functions from above to implement the same application in a more structured manner.
type RollDie = (s: Sides) => Roll;
type LogRoll = (r: Roll) => void;
type Main2 = () => void;
const rollDie: RollDie = (sides) => {
const ratio = Math.random();
return rollFromRatio(sides)(ratio);
};
const logRoll: LogRoll = (roll) => {
console.log(printableFromRoll(roll));
};
const diceMain2: Main2 = () => {
logRoll(rollDie(6));
};
In the example above rollDie
procedure is impure because it contains a random element
and therefore produces different results for the same input. The logRoll
procedure is
impure because it causes logging to happen. It's result clearly depends on when, and how
many times, the computation is executed. Finally, diceMain2
is obviously impure since
it does both random and logging.
In order to minimize the amount of impure procedures, it is possible to split them into two parts -- a pure function that takes arguments and produces an impure computation with no arguments. This way the caller can decide wether or not they wish to execute the computation and deal with the effects and nondeterminism. Below is the same code example restructured in this manner.
// impure computation that returns T
type IO<T> = () => T;
// pure functions
type RollDie3 = (s: Sides) => IO<Roll>;
type LogRoll3 = (r: Roll) => IO<void>;
type Main3 = IO<void>;
const rollDie3: RollDie3 = (sides) => {
const fr = rollFromRatio(sides);
return () => {
const ratio = Math.random();
return fr(ratio);
};
};
const logRoll3: LogRoll3 = (roll) => {
const printable = printableFromRoll(roll);
return () => {
console.log(printable);
};
};
const rollSixSided3 = rollDie3(6);
const diceMain3: Main3 = () => {
const roll = rollSixSided3();
const logger = logRoll3(roll);
logger();
};
We can use tooling from a functional programming library such as fp-ts to hide some of the complexity related to combining individual IO thunks into a single application. Below is one more iteration of the same code example but this time none of the IO thunks are visible in the apllication source code. Note however, that the types match those from the previous iteration.
import { log } from 'fp-ts/Console';
import { flow, pipe } from 'fp-ts/function';
import { chain, map } from 'fp-ts/IO';
import { random } from 'fp-ts/Random';
const rollDie4: RollDie3 = (sides) => pipe(random, map(rollFromRatio(sides)));
const logRoll4: LogRoll3 = flow(printableFromRoll, log);
const rollSixSided4 = rollDie4(6);
const diceMain4: Main3 = pipe(rollSixSided4, chain(logRoll4));
TypeScript throws away the types of your function inputs by default. You can preserve the types by annotating your function with type variables.
function createPair<A, B>(first: A, second: B): [A, B] {
return [first, second];
}
Above we defined the types in the function definition but it is also ok
to define the types for the const that contains the function. As long
as TypeScript can infer the types you should be ok. Please note how a
and b
are meaningless in the example below but are still required in
the code.
type PairCreator = <A, B>(a: A, b: B) => [A, B];
const createPair2: PairCreator = (first, second) => [first, second];
Note that types A
and B
in the example above only support operations
that can be performed regardless of the more detailed types of the
inputs a
and b
. You may need to extend the signature if you wish to
perform operations. For example if you wish to perform numeric
multiplication or summation on A
you would need to state A extends number
.
const double = <A extends number>(a: A) => a * 2;
const increment = <A extends number>(a: A) => a + 1;
The code base makes heavy use of pipelines. Pipelines are the Javascript equivalent for UNIX pipes (the ls|grep omg
sort of thing). The pipeline operator |>
is an upcoming starndard. TypeScript is currently waiting for TC39 standardization. However, fp-ts provides a similar pipe
function that works today. It works as follows.
// import { pipe } from 'fp-ts/lib/function';
pipe(5, double, double, increment, double); // 42
When debugging runtime errors it may be desirable to add debug prints into a pipe using console.log. The type language does not contain print statement. However, identity functions with type signatures can be used to check the types where console.log lines are used to check runtime values.
pipe(
5,
double,
(x) => {
console.log(x);
return x;
}, // check value
double,
(x: number) => x, // check type
increment,
double,
);
Another situation where the type information is lost is when we convert our data structures to JSON. Or the data might not have types to begin if we read it from some external source. We use io-ts for such types since this provides us with the type signature but also a matching runtime validator.
In the example below we define io-ts data type user with the static
type type User =
and runtime validator const User =
. The static
type is equivalent to { userId: number, name: string }
.
import * as t from 'io-ts';
const User = t.type({
userId: t.number,
name: t.string,
});
type User = t.TypeOf<typeof User>;
In the example below we pass a json structure to the runtime validator.
import { validator } from 'io-ts-validator';
const json: unknown = JSON.parse('{"userId":123,"name":"Bob"}');
const user: User = validator(User).decodeSync(json);
When working with types cstandard JavaScript tools are often too generic. TypeScript supports modifying the types with explicit type assertions. However, using proper tooling may reduce the need to use explicit type casts.
We have collected some of the most basic utilities from typescript, fp-ts and io-ts into maasglobal-prelude-ts package to make their use more convenient.
lets import the tools under name P
for easy access
import * as P from 'maasglobal-prelude-ts';
Below are some examples cases where we can use the utilities to our benefit.
const input1: Record<string, string> = {
foo: 'foo',
};
const output1a: Record<string, number> = {
...input1, // string becomes number :(
omg: 123,
};
const output1b: Record<string, number> = P.pipe(
// @ts-expect-error error detected :)
input1,
P.Record_.upsertAt('omg', 123),
);
const input3: Array<number> = [1, 2, 3];
const output3a = (input3 as Array<string | number>).concat(['foo']); // cast required :(
const output3b = P.pipe(
input3, // no cast required :)
P.Array_.concatW(['foo']),
);
const output3c = P.pipe(
// @ts-expect-error error if we want :)
input3,
P.Array_.concat(['foo']),
);
const input2 = ['foo'];
const [output2a] = input2.concat(['bar']);
// @ts-expect-error // can be undefined :(
const test2a: string = output2a;
const [output2b] = P.pipe(input2, P.NonEmptyArray_.concat(['bar']));
const test2b: string = output2b; // always defined :)
const input4 = 'hello-world';
const [output4a] = input4.split('-');
// @ts-expect-error can be undefined :(
const test4a: string = output4a;
const [output4b] = P.pipe(input4, P.string_.split('-'));
const test4b: string = output4b; // always defined :)
const [output4c] = P.string_.split('-')(input4); // without pipe
const test4c: string = output4c;
This uses our custom IIFE helper.
const raining = true;
// forced to use ternary :(
const output5a = raining ? 'rubber boots' : 'regular shoes';
// regular if statement works :)
const output5c = P.ii(() => {
if (raining) {
return 'rubber boots';
}
return 'regular shoes';
});
Previous chapter showcased some of the utilities included in our prelude. This chapter contains a more exhaustive list of types of values that the included utilities work on.
const zero = P.identity(0);
const one = P.function_.increment(zero);
const two = P.flow(P.function_.increment, P.function_.increment)(zero);
const three = [1, 2, 3, 4, 5, 6].find(P.Predicate_.not((x) => x < 3));
const four = P.pipe(
zero,
P.function_.increment,
P.function_.increment,
P.function_.increment,
P.function_.increment,
);
function yesOrNo(input: boolean): 'yes' | 'no' {
if (input === true) {
return 'yes';
}
if (input === false) {
return 'no';
}
return P.absurd(input);
}
const bool: boolean = true;
const boolConstant: true = true;
const string: string = 'foo';
const strConstant: 'foo' = 'foo';
const number: number = 123;
const numConstant: 123 = 123;
- Array
- NonEmptyArray array with at least one item
const array: Array<string | number> = ['foo', 123, 'bar', 456];
const pair: [string, number] = ['foo', 123];
- Record key/value mapping
const record: Record<string, string | number> = {
foo: 'foo',
bar: 123,
};
const struct: { foo: string; bar: number } = {
foo: 'foo',
bar: 123,
};
- Option value or "null"
const anOptionalValue: P.Option<number> = {
_tag: 'Some',
value: 123,
};
// or
const anOptionalValue2: P.Option<number> = P.Option_.some(123);
const noOptionalValue: P.Option<number> = {
_tag: 'None',
};
// or
const noOptionalValue2: P.Option<number> = P.Option_.none;
- Either value or error
// type aliases are useful for humans and maintainability
type Failure = string;
const eitherSucess: P.Either<Failure, number> = {
_tag: 'Right',
right: 123,
};
// or
const eitherSucess2: P.Either<Failure, number> = P.Either_.right(123);
const eitherFailure: P.Either<Failure, number> = {
_tag: 'Left',
left: 'Unable to calculate number',
};
// or
const failure2: P.Either<Failure, number> = P.Either_.left('Unable to calculate number');
type Divide = (d: number) => (i: number) => P.Either<Failure, number>;
const divide: Divide = (divider) => {
return (input) => {
if (divider === 0) {
return {
_tag: 'Left',
left: 'Division by zero error',
};
}
return {
_tag: 'Right',
right: input / divider,
};
};
};
// or
const divide2: Divide = (divider) => (input) =>
P.pipe(
P.Either_.right(divider),
P.Either_.filterOrElse(
(d) => d !== 0,
() => 'Division by zero error',
),
P.Either_.map((d) => input / d),
);
- These extends either with
both
that can be used for warnings.
Lazy is used for wrapping pure but heavy computations in a "thunk" () => factorize(someBigNumber)
.
It lets the caller discard the result without evaluating the computationaly heavy part.
IO wraps synchronous effects in a "thunk" () => { ... your code goes here }
.
It lets the caller discard the result without executing the computation.
Unlike with Lazy functions, the computations are free to have side effects.
For example printing on the screen or returning a random number.
const printHello: P.IO<void> = () => console.log('hello');
// or
const printHello2: P.IO<void> = P.Console_.log('hello');
type Printer = (x: unknown) => P.IO<void>;
const printer: Printer = (x) => () => console.log(x);
// or
const printer2: Printer = P.Console_.log;
type SumPrinter = (y: number) => (x: number) => P.IO<void>;
const printSum: SumPrinter = (y) => (x) => () => console.log(x + y);
// or
const printSum2: SumPrinter = (y) => (x) => P.Console_.log(x + y);
type Dice = (sides: number) => P.IO<number>;
const dice: Dice = (sides) => () => 1 + Math.floor(Math.random() * sides);
// or
const dice2: Dice = (sides) => P.Random_.randomInt(1, sides);
type D6 = P.IO<number>;
const d6: D6 = dice(6);
// P.IOEither<A, B> is the same as P.IO<P.Either<A, B>>
type D6Divider = (i: number) => P.IOEither<Failure, number>;
const d6Divider: D6Divider = (input) => () => {
const diceRoll = d6();
const logger = printer(diceRoll);
logger();
const diveByDiceRoll = divide(diceRoll);
return diveByDiceRoll(input);
};
// or
const d6Divider2: D6Divider = (input) =>
P.pipe(
d6,
P.IO_.chainFirst(P.Console_.log),
P.IO_.map((diceRoll) => P.pipe(input, divide(diceRoll))),
);
Task wraps asynchronous effects in a "thunk" async () => { ... your code goes here }
.
It is a way of letting the caller discard the result without executing the computation.
Task is essentially same as IO but uses promises to wrap asynchronous return values.
// Task and TaskEither is similar to IO and IOEither but work with Promises
type AsyncPrinter = <S>(s: S) => P.Task<void>;
const asyncPrinter: AsyncPrinter = (x) => async () => console.log(x);
// or
const asyncPrinter2: AsyncPrinter = P.Task_.fromIOK(P.Console_.log);
Reader is used for injecting dependencies before executing the computation
// ReaderTaskEither takes dependencies and returns a task with result
type WithoutDeps = (
i: number,
) => P.ReaderTaskEither<{ print: AsyncPrinter }, Failure, number>;
const withoutDeps: WithoutDeps =
(input) =>
({ print }) =>
async () => {
const diceRoll = d6();
const logger = print(diceRoll);
await logger();
const diveByDiceRoll = divide(diceRoll);
return diveByDiceRoll(input);
};
// or
const withoutDeps2: WithoutDeps =
(input) =>
({ print }) =>
P.pipe(
P.Task_.fromIO(d6),
P.Task_.chainFirst(print),
P.Task_.map((diceRoll) => P.pipe(input, divide(diceRoll))),
);
The example code in the Decoding JSON chapter above contained some
side-effects and introduced a null
into the type signature. These are
properties that we would typically want to avoid in the functional parts
of our code. In the example below, we extend this example into a tiny
functional application that reads user information from a hypothetical
API and prints out the user information.
import { Errors as ValidationErrors } from 'io-ts-validator';
type Api = {
userInfo: (userId: number) => Promise<string>;
};
const api: Api = {
userInfo: (userId) => {
return Promise.resolve(JSON.stringify({ userId: userId, name: 'Bob' }));
},
};
const logResult = (result: unknown) => () => console.log(result);
const main = pipe(
P.TaskEither_.tryCatch(
() => api.userInfo(123),
(error) => ({ error: 'API Error', info: [String(error)] }),
),
P.TaskEither_.chain((response) =>
P.TaskEither_.fromEither(
P.Either_.tryCatch(
() => JSON.parse(response),
(error) => ({ error: 'Parse Error', info: [String(error)] }),
),
),
),
P.TaskEither_.chain((json) =>
pipe(
validator(User).decodeEither(json),
P.Either_.mapLeft((errors) => ({ error: 'Decode Error', info: errors })),
P.TaskEither_.fromEither,
),
),
P.TaskEither_.fold(
(error) => P.Task_.fromIO(logResult(error)),
(user) => P.Task_.fromIO(logResult(user)),
),
);
main();
The code example in previous chapter doesn't have many type signatures. This is not a problem since TypeScript is often able to infer the types for your code as long as you are using typesafe building blocks. Indeed, that is one of the reasons what makes functional programming a good match for strongly typed code.
Even thought explicit variable names and type signatures are not necessary for the compiler. It is often a good idea to provide some to make the code more readable. When you are building your first application it is a good idea to write type signatures for every tiny piece of the puzzle. This will help you pinpoint down possible errors in your code. Below is the code example from previous chapter with way too many type signatures. However, this is exactly what you might want to confirm that the type inferance matches your expectation. You may then remove unnecessary clutter as you get more confident about your work.
type BuildMain = P.ReaderTask<{ api: Api }, void>;
const buildMain: BuildMain = ({ api }) => {
// IO Type Definitions
type UserC = t.Type<{
userId: number;
name: string;
}>;
const User: UserC = t.type({
userId: t.number,
name: t.string,
});
type User = t.TypeOf<typeof User>;
// Typesafe Output Mechanism
const logResult = (result: unknown): P.IO<void> => {
const action: P.IO<void> = () => {
// effects are OK inside IO
console.log(result);
};
return action;
};
// Internal Type Definitions
type Response = string;
type Json = unknown;
enum Errors {
Api = 'API Error',
Parse = 'Parse Error',
Decode = 'Decode Error',
}
interface ErrorInfo {
error: Errors;
info: Array<string>;
}
const ErrorInfo_ = {
fromApiError: (error: unknown): ErrorInfo => ({
error: Errors.Api,
info: [String(error)],
}),
fromParseError: (error: unknown): ErrorInfo => ({
error: Errors.Parse,
info: [String(error)],
}),
fromDecodeError: (errors: ValidationErrors): ErrorInfo => ({
error: Errors.Decode,
info: errors,
}),
};
// Architecture Description
type ResponseRetriever = P.TaskEither<ErrorInfo, Response>;
type ResponseParser = (
s: P.TaskEither<ErrorInfo, Response>,
) => P.TaskEither<ErrorInfo, Json>;
type UserDecoder = (j: P.TaskEither<ErrorInfo, Json>) => P.TaskEither<ErrorInfo, User>;
type ResultReporter = (j: P.TaskEither<ErrorInfo, User>) => P.Task<void>;
// Functional Implementation
const responseRetriever: ResponseRetriever = P.TaskEither_.tryCatch(
(): Promise<Response> => {
// failure and effects are OK inside TaskEither
return api.userInfo(123);
},
ErrorInfo_.fromApiError,
);
const responseParser: ResponseParser = P.TaskEither_.chain(
(response: Response): P.TaskEither<ErrorInfo, Json> => {
const parsingResult: P.Either<ErrorInfo, Json> = P.Either_.tryCatch((): Json => {
// failure is OK inside an Either (but effects are NOT)
return JSON.parse(response);
}, ErrorInfo_.fromParseError);
// We "lift" the result to TaskEither to match the type of the chain
return P.TaskEither_.fromEither(parsingResult);
},
);
const responseDecoder: UserDecoder = P.TaskEither_.chain(
(json: Json): P.TaskEither<ErrorInfo, User> => {
type ErrorTransform = (
r: P.Either<ValidationErrors, User>,
) => P.Either<ErrorInfo, User>;
const errorTransform: ErrorTransform = P.Either_.mapLeft(
ErrorInfo_.fromDecodeError,
);
const rawDecodeResult: P.Either<ValidationErrors, User> =
validator(User).decodeEither(json);
const decodeResult: P.Either<ErrorInfo, User> = errorTransform(rawDecodeResult);
return P.TaskEither_.fromEither(decodeResult);
},
);
const resultReporter: ResultReporter = P.TaskEither_.fold(
(error: ErrorInfo): P.Task<void> => {
const action: P.IO<void> = logResult(error);
return P.Task_.fromIO(action);
},
(user: User): P.Task<void> => {
const action: P.IO<void> = logResult(user);
return P.Task_.fromIO(action);
},
);
return resultReporter(responseDecoder(responseParser(responseRetriever)));
};
const strictMain: P.Task<void> = buildMain({
api: {
userInfo: (userId: number): Promise<string> => {
return Promise.resolve(JSON.stringify({ userId: userId, name: 'Bob' }));
},
},
});
// Our entire application is typesafe up to this point. The execution of the
// main Task<void> (below) breaks referential transparency and returns
// Promise<void>. We chose type Task<void> for main to indicate that we were
// building a standalone application. If we were building a functional plugin
// we might have chosen main to have type Task<User|null> to let the parent
// application access the result of the computation.
strictMain();