Getting started
Installation
Requirements
Typescript
Technically Typescript with version 4.8.0
or higher should work, but we recommend using version >= 5
when possible.
Also it is important that you have strict
or strictNullChecks
enabled in your tsconfig.json
:
{
"compilerOptions": {
"strict": true
}
}
Node
Tested with Node.js version 16
and higher.
Install the library
You can install the library using your favorite package manager.
$ npm add typescript-result
$ pnpm add typescript-result
$ yarn add typescript-result
$ bun add typescript-result
Your first Result
Let's start by refactoring a function that reads a file and parses its content:
function readConfig(filePath: string): Config {
const contents = fs.readFileSync(filePath, "utf-8");
return JSON.parse(contents);
}
This code has a serious flaw: multiple things can go wrong, but the function signature gives no indication of this. The consumer expects this function to always return a Config
object, but what happens when the file doesn't exist? What if the contents aren't valid JSON? What if the JSON structure doesn't match the expected Config
shape?
Let's refactor this code using a Result
type to make these potential failures explicit.
Defining errors
First, we need to define some errors so what we can distinguish between different error cases:
class IOError extends Error {
readonly type = "io-error";
}
class ParseError extends Error {
readonly type = "parse-error";
}
class ValidationError extends Error {
readonly type = "validation-error";
}
INFO
Please disregard the readonly type
property in the error classes for now. It's not necessary for the library to work, but it can be useful for type narrowing and debugging purposes. For more information, see A note on errros.
Returning a Result
With these error classes in place, we can now express the outcome of the readConfig
function as a Result
type. In case of a caught error, we will return a Result.error()
with the appropriate error. In case of success, we will return a Result.ok()
with the parsed configuration object.
type Config = {
name: string;
version: number;
}
function readConfig(filePath: string) {
let contents: string;
try {
contents = fs.readFileSync(filePath, "utf-8");
} catch (error) {
return Result.error(
new IOError(`Unable to read file: ${filePath}`, { cause: error })
);
}
let json: unknown;
try {
json = JSON.parse(contents);
} catch (error) {
return Result.error(
new ParseError(`Unable to parse JSON from file: ${filePath}`)
);
}
try {
const config = parseConfig(json);
return Result.ok(config);
} catch (error) {
return Result.error(
new ValidationError(
`Invalid configuration in file: ${filePath}`,
{ cause: error }
)
);
}
}
const result = readConfig("config.json");
//
If you look at the final result
, you'll see exactly what the outcome of the function can be. And while technically the above code is correct, it is very verbose. Luckily, the library provides a way to make this code more concise.
Adding the Result.try
helper
First, we will introduce the Result.try
helper function. This function is basically a wrapper around the try/catch
block that allows us to handle errors in a more concise way. It tries to execute the provided function and wrap the returned value in a Result.ok()
, or catch any errors and wrap them in a Result.error()
. Optionally, you can provide a second callback function that allows you to transform the error before wrapping it in a Result.error()
. Let's see how this looks:
function readConfig(filePath: string) {
const contentResult = Result.try(
() => fs.readFileSync(filePath, "utf-8"),
(error) => new IOError(`Unable to read file: ${filePath}`, { cause: error })
);
if (contentResult.isError()) {
return contentResult;
}
const jsonResult = Result.try(
() => JSON.parse(contentResult.value),
() => new ParseError(`Unable to parse JSON from file: ${filePath}`)
);
if (jsonResult.isError()) {
return jsonResult;
}
const configResult = Result.try(
() => parseConfig(jsonResult.value),
(error) => new ValidationError(
`Invalid configuration in file: ${filePath}`,
{ cause: error }
)
);
return configResult;
}
Extracting steps with Result.wrap
Ok, no more try/catch
blocks, but the code is still very verbose. To improve readability, we can extract each step into a separate function. This will allow us to focus on the main logic of the readConfig
function without getting lost in the details of error handling. This is where Result.wrap
comes in handy. This is very similar to Result.try
, but instead of executing a function directly, it returns a function instead:
const readFile = Result.wrap(
(filePath: string) => fs.readFileSync(filePath, "utf-8"),
(error) => new IOError(`Unable to read file`, { cause: error }),
);
const parseJSON = Result.wrap(
(data: string) => JSON.parse(data) as unknown,
() => new ParseError(`Unable to parse JSON`)
);
const parseConfig = Result.wrap(
(data: unknown) => {
/* skipped for brevity */
return data as Config;
},
(error) => new ValidationError(`Invalid configuration`, { cause: error }),
);
function readConfig(filePath: string) {
const contentResult = readFile(filePath);
if (contentResult.isError()) {
return contentResult;
}
const jsonResult = parseJSON(contentResult.value);
if (jsonResult.isError()) {
return jsonResult;
}
return parseConfig(jsonResult.value);
}
Chaining operations
This is a step in the right direction, but we can still improve the code further. Did you notice how we check for errors after each step? This is a pattern both loved and hated in languages like Go. While it makes things explicit, it can also lead to a lot of boilerplate code. To make our code more ergonomic, we can introduce chaining: performing multiple operations on a Result
instance in a more concise way. In 90% of the cases Result.map()
is the method you want to use. Let's see how this works in practice:
function readConfig(filePath: string) {
return readFile(filePath)
.map((contents) => parseJSON(contents))
.map((json) => parseConfig(json));
}
INFO
Most operations on Result
instances are polymorphic, meaning you can return different types of values from them — literal values, promises, or even other Result
instances as shown above. This flexibility makes it easy to compose operations and build complex workflows.
TIP
If you have worked with typescript-result
for a while, you'll notice that often there are multiple ways to achieve the same result. This is intentional, as we want to provide you with the flexibility to choose the approach that best fits your use case. The library is designed to be ergonomic and easy to use, so you can focus on writing clean and maintainable code.
To illustrate this point, we could have 'inlined' the json parsing using mapCatching
for instance, instead of extracting it into a separate function:
function readConfig(filePath: string) {
return readFile(filePath)
.mapCatching(
(contents) => JSON.parse(contents),
() => new ParseError(`Unable to parse JSON`)
)
.map((json) => parseConfig(json));
}
(Optional) 'do-style' syntax using generators
Remember this pattern from a few examples back?
const result = operation();
if (result.isError()) {
return result;
}
With Rust you can use the ?
operator to make this more concise, but in TypeScript we don't have that luxury. However, we can use generator functions to achieve a similar effect. This is an optional feature of the library, and you can choose to use it or not. For more information, see Chaining vs. generator syntax.
Here's a quick example of how this works:
function* readConfig(filePath: string) {
const contents = yield* readFile(filePath);
const json = yield* parseJSON(contents);
const config = yield* parseConfig(json);
return config;
}
const result = Result.gen(readConfig("config.json"));
Next steps
Hopefully this gives you a good idea of how to get started with typescript-result
. For more information on how to use this library, please continue with the guide. If you want to see more examples, check out the examples. Ready to give it a spin? Try it out for yourself in our playground.