0.0.5 • Published 2 years ago

typesafely v0.0.5

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

TypeSafely

TypeScript types (Option, Result and AsyncResult) designed to emulate Rust types and patterns.

There are a lot of npm libraries which do this. Here's another.

type CalculationResult = Result<CalculationOk, CalculationError>;

// Return a Result from a calculation which may fail
const performCalculationSafely = (): CalculationResult => {
  try {
    const data = handleCalculation();
    return Ok(data);
  } catch (e) {
    return Err(e.message);
  }
};

// The data variable is now a Result type which must be either Ok or Err
const data: CalculationResult = performCalculationSafely();

// Now pass data to the matchResult function and explicitly handle each state:
matchResult(data, {
  ok: (x) => x,
  err: (e) => e,
});

Usage

This library provides three high level types:

Option
Result
AsyncResult

The Option and Result types are modeled after the same types in Rust. The AsyncResult type is like a Result but includes an additional state to represent "loading" and is intended to be used for data which is produced asynchronously.

In addition to these type primitives there are a few additional helper methods and functions:

  • 'matching' functions, matchOption, matchResult, matchAsyncResult which operate like Rust match expressions.
  • unwrap and unwrapOr methods. Like in Rust, unwrap will "panic" if a type was not in an "Ok" state.
  • if[Ok|Err|Loading] and if[Some|None] methods which allow you to conditionally run some logic if a type is of a particular variants. Non-matching variants will be ignored.

Option Type

// Create a Some Option variant and pass it to a match statement
const opt: Option<number> = Some(900);

// The some branch will run and x will be 900
matchOption(opt, {
  some: (x) => console.log("[Some variant]:", x),
  none: () => console.log("[None variant]"),
});

// Create a None Option variant and pass it to a match statement
const opt: Option<number> = None();

// The none branch will run:
matchOption(opt, {
  some: (x) => console.log("[Some variant]:", x),
  none: () => console.log("[None variant]"),
});

Result Type

// Create an Ok Result variant and pass it to a match statement
const result: Result<number, string> = Ok(100);

// The ok branch will run and x will be 100:
matchResult(result, {
  ok: (x) => console.log("[Ok variant]:", x),
  err: (e) => console.log("[Err variant]:", e),
});

// Create an Ok Result variant and pass it to a match statement
const err: Result<number, string> = Err("Error");

// The err branch will run and e will be "Error"
matchResult(result, {
  ok: (x) => console.log("[Ok variant]:", x),
  err: (e) => console.log("[Err variant]:", e),
});

AsyncResult Type

The AsyncResult is similar to the Result type but includes another variant to represent loading state. This is especially useful for modelling asynchronously fetched data and provides strong guarantees you are handling the appropriate state of the response. No need to independently set and update loading/error/response states, which is error prone. No need to write out fragile logic like !loading && !response to check for error states.

const FetchDataComponent: React.FC = () => {
  // Use an AsyncResult to model some asynchronously fetched data
  const [data, setData] = React.useState<AsyncResult<number, string>>(
    AsyncResultLoading(),
  );

  const fetchData = async () => {
    try {
      // Handle fetching data here...
      setData(AsyncOk({ data: "ok!" }));
    } catch (err) {
      setData(AsyncErr("Failed to fetch data..."));
    }
  }

  React.useEffect(() => {
    fetchData();
  });

  return (
    <>
      {matchAsyncResult(data, {
        ok: x => <p>Data: {JSON.stringify(x)}<p>,
        err: e => <p>Error fetching data: {JSON.stringify(e)}</p>,
        loading: () => <p>Loading...</p>,
      })}
    </>
  );
};

Motivation

The main idea behind this approach is twofold and similar to the rationale for the similar design in Rust:

  • Result types can be used to model values which may represent an error state and avoid throwing and catching errors (which is difficult to type-check correctly in TypeScript). A Result makes it explicitly that a function may result in an error state, which calling code must handle.
  • Option types can be used to model values which may be in a present or absent state, which otherwise in JS/TS are usually modeled with null or undefined. An Option makes this presence or absence more explicit and avoids issues like 0 == false "" == false" etc.

For instance, imagine you have some value which is declared but not initialized yet.

const value: number = null;
// Somewhere else:
value = 50;

Later you want to check if the value is initialized and then run some other code:

if (!!value) {
  // Run some other code which expects value to be defined
}

But what if actually, some other code had already set this value to be 0? Then your !!value check would result in false and your code wouldn't run.

This is a simple example but a common pitfall and one which TypeScript can't easily protect against. Consider instead this:

const value: Option<number> = None();

matchOption(value, {
  some: (x) => x, // Handle non-empty case
  none: () => null, // Handle empty case
});

This code avoids the above issues completely.

0.0.5

2 years ago

0.0.4

2 years ago

0.0.3

2 years ago

0.0.2

2 years ago

0.0.1

2 years ago