β€’

tech

Safety with types: Best practices

Types are designed to protect your code base from any possible misuse. There are some strategies and best practices that will make you even safer from runtime bugs, guaranteed.


Sandro Maglione

Sandro Maglione

Software

Types test your code πŸ”¬

Type safety collapses runtime bugs to compile-time guarantees.

There are some simple best practices that will guarantee safety in your apps long-term. Here are the 3 strategies I use the most πŸ‘‡


Brands

You are working on a blogging app, so naturally you can edit a post.

What's the signature of the editPost function?

Here is the wrong (not ideal) answer:

const editPost = (postId: string, post: Post) => {
  /// ...
}

We are not safe here. Is postId really any string?

The general assumption is that you get a Post from an API, and the user can edit it. Therefore, postId must come from the API, it's not just string πŸ€”

Coming back to safety means using branded types (from effect) ✨

It works like this:

  1. Define a Schema for a branded type (Schema.brand)
  2. Decoded from the schema when fetching the Post
  3. Use postId from the API to make the edit request
import { Schema } from "effect"

const PostId = Schema.String.pipe(Schema.brand("PostId"));
type PostId = typeof PostId.Type;

const Post = Schema.struct({
  postId: PostId,
});

// Get `Post` from an API
const getPost: Effect<Post> = getPostFromApi.pipe(
  // Decode schema for valid `Post`
  Effect.flatMap(Schema.decode(Post)),
);

const editPost = (
  postId: PostId,
  post: Omit<typeof Post.Type, "postId">
) => {
  /// ...
}

Now it's impossible to call editPost unless you have a valid PostId. And the only place where you get a valid PostId is after calling getPost.

Result: you guaranteed that editing a post is safe in all cases (all!) πŸ’―

Note: You are safe as long as PostId is verified. Any "exception" (e.g. using as unknown as PostId) when creating branded types tosses you out of safety 😬

Literal types

Safety requires strictness πŸ‘Š

Being strict with types means allowing only the minimal set of values to cover your use case πŸ”¬

Back to the Post example.

First, it's more safe an maintainable to avoid something like isPublished: boolean, and instead opt for a more general status field.

Second, don't type status: string. Use a string literal instead:

import { Schema } from "effect"

const Post = Schema.struct({
  postId: PostId,
  status: Schema.Literal("published", "draft"),
});

Major advantages:

  • You can extend status with other states (e.g. in-review)
  • You avoid impossible states on a type-level
  • You can pattern match on all literals

Bonus tip: you can type a Record key using literals. You will be required to provide values for all keys.

I use this all the times for labels πŸ“§

type Language = "en" | "it" | "jp";
type LanguageLabel: Record<Language, string> = {
  en: "English",
  it: "Italian",
  jp: "Japanese"
};

NonEmptyArray

Arrays are insidious, specifically this: arr[0] πŸ‘€

First, never forget to add noUncheckedIndexedAccess to your tsconfig.json.

noUncheckedIndexedAccess adds | undefined to the typeof of arr[0] πŸ™Œ

Second, verify a value exists every time you access any index in an array. With effect you can make arr[0] safe using NonEmptyArray.

There is only 1 real way of checking for non-empty array πŸ™Œ

Image
David K 🎹
David K 🎹
@DavidKPiano

Tip: instead of checking the array length to see if it's non-empty, you can check the first item when all the items are expected to be truthy. ❌ if (arr.length > 0) βœ… if (arr[0]) It's shorter, cleaner, and plays nicer with TypeScript's `noUncheckedIndexedAccess` setting.

interface Item {
  name: string;
}

function processArray1(arr: Item[]) {
  if (arr.length > 0) {
    // Still need type narrowing or ! to avoid TS error
    arr[0].name;
  }
}

function processArray2(arr: Item[]) {
  if (arr[0]) {
    // Cleaner, type-safe with noUncheckedIndexedAccess
    arr[0].name;
  }
}
24
Reply

Even without effect, type safe arrays are a simple trick:

type NonEmptyArray = {
  [0]: string;
} & string[];

// Property '[0]' is missing in type '[]' but required in type '{ 0: string; }'
const notValid: NonEmptyArray = [];

const valid: NonEmptyArray = ["valid"];

And guess what? You can even make something like ArrayWithAtLeastTwoElements:

type ArrayWithAtLeastTwoElements = {
  [0]: string;
  [1]: string;
} & string[];

// Type '[]' is not assignable to type '{ 0: string; 1: string; }'
const notValid: ArrayWithAtLeastTwoElements = [];

// Property '[1]' is missing in type '[string]' but required in type '{ 0: string; 1: string; }'
const stillInvalid: ArrayWithAtLeastTwoElements = ["valid"];

const valid: ArrayWithAtLeastTwoElements = ["valid", "also-valid"];

Three weeks streak of effect on your X feed. Everyone is jumping in:

If you are jumping in right now as well, join the Discord channel:

See you next πŸ‘‹

Start here.

Every week I dive headfirst into a topic, uncovering every hidden nook and shadow, to deliver you the most interesting insights

Not convinced? Well, let me tell you more about it