SafeFn

Introduction

Why SafeFn?

SafeFn is a library to easily build fully typed functions with validated input and output, procedure chaining, callbacks and more. This is ideal for functions that are at the edge of your applications, like Server Actions!

Existing solutions like ZSA and Next-Safe-Action are great, but often fall short when it comes to properly handling errors. SafeFn integrates NeverThrow to handle errors in a typesafe manner. While SafeFn really shines when used together with NeverThrow, incremental adoption is easy as the only requirement is that your handler returns a Result created through NeverThrow's ok() or err().

Besides the typesafety, SafeFn also aims to improve on existing solutions by allowing for flexible composition and configuration of functions.

NeverThrow

For a quick introduction to NeverThrow, check out this Twitter thread by Matt Pocock. Their docs can be found here.

Put very briefly, NeverThrow provides a Result type which enables typed errors. A result can be created through the ok() and err() functions, and can be consumed through result.value and result.error.

function someGoodSomeBad() {
  if (Math.random() > 0.5) {
    return ok("Yay!" as const);
  } else {
    return err("Boo!" as const);
  }
}
 
const res = someGoodSomeBad();
 
if (res.isOk()) {
  console.log(res.value);
  //               ^? "Yay!"
} else {
  console.log(res.error);
  //               ^? "Boo!"
}

Quick SafeFn examples

Take the simple case of a function that creates a todo for a user (very original I know). We first need to make sure the user is signed in, then validate the input of the todo, store it in the database if it's valid and then return it.

All of this needs to be done while taking care of errors that might occur in any of these steps.

const withAuth = createSafeFn().handler(async () => {
  const user = await auth.getSignedInUser();
  if (!user) {
    return err({
      code: "NOT_AUTHORIZED",
    });
  }
  return ok(user);
});
const createTodo = createSafeFn()
  .use(withAuth)
  .input(
    z.object({
      title: z.string().min(2),
      description: z.string().min(2),
    }),
  )
  .handler(async (args) => {
    const user = args.ctx;
    const todo = await db.todo.create({
      title: args.input.title,
      description: args.input.description,
      userId: user.id,
    });
    return ok(todo);
  });

Considering both of these functions can throw we should probably add some error handling as such:

const withAuth = SafeFn.new()
  ...
  .catch((e) => {
    return err({
      code: "AUTH_ERROR",
    });
  });
 
const createTodo = SafeFn.new(withAuth)
  ...
  .catch((e) => {
    return err({
      code: "DB_ERROR",
    });
  });

This results in a fully typed function with the following return type:

type res = ResultAsync<
  {
    id: string;
    title: string;
    description: string;
  },
  | {
      code: "DB_ERROR";
    }
  | { code: "AUTH_ERROR" }
  | { code: "NOT_AUTHORIZED" }
  | {
      code: "INPUT_PARSING";
      cause: z.ZodError<{ title: string; description: string }>;
    }
>;

we can easily call this function using the run() method:

const res = await createTodo({...})
 
if (res.isOk()){
  ...
} else if (res.isErr()){
  if (res.error.code === "AUTH_ERROR"){
    ...
  }
}

or on the client by creating an action and calling it with useServerAction(), in this example we're adding a platform specific callback to handle revalidation:

server/... .ts
"use server";
 
const createTodoAction = createTodo()
  .onSuccess(async (args) => {
    revalidatePath(`/todos/${args.value.id}`);
  })
  .createAction();
client/... .tsx
const { execute, result, isPending } = useServerAction(createTodoAction);

However, the magic of the integration of NeverThrow really comes out if your other functions are also returning a Result or ResultAsync. Instead of passing a regular function via handler() you can also use safeHandler(). This builds on Neverthrow's safeTry() and takes in a generator function. This generator function receives the same args as handler(), but it allows you to yield * other results using .safeUnwrap(). This is meant to emulate Rust's ? operator and allows a very ergonomic way to write "return early if this fails, otherwise continue" logic.

This function has the same return type as the handler() example above.

const withAuth = createSafeFn().safeHandler(async function* () {
  const user = yield* auth.getSignedInUser().safeUnwrap();
  return ok(user);
});
 
const createTodoAction = createSafeFn()
  .use(withAuth)
  .input(
    z.object({
      title: z.string().min(2),
      description: z.string().min(2),
    }),
  )
  .safeHandler(async function* (args) {
    const user = args.ctx;
    const todo = yield* db.todo
      .create({
        title: args.input.title,
        description: args.input.description,
        userId: user.id,
      })
      .safeUnwrap();
    return ok(todo);
  });

On this page