Skip to content

Instantly share code, notes, and snippets.

@tantaman
Last active February 22, 2020 16:54
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tantaman/e2125d16764cc2159d3bdeac01639c86 to your computer and use it in GitHub Desktop.
Save tantaman/e2125d16764cc2159d3bdeac01639c86 to your computer and use it in GitHub Desktop.
Using Intersection and Union types to add policy awareness to code

Problem

With the advent of regulation, app development has gotten an order of magnitude more complicated. Every time we interact with a user we have to check that we're not violating any agreements (email opt out, cookie opt out, only using their phone number for 2fac, etc) made with that user. In addition, functions that used to apply to all users now have restrictions inside of them, causing them to only apply to certain users.

Example

Take sending an email to a user as an example. In the infancy of your app it may have been ok to email all of your users but as you grew you needed to give users a way to opt out of emails. The simplest approach here is to add a line in the email out code that checks if the user is allowed to receive emails.

function sendEmailTo(user) {
  if (!canReceiveEmails(user)) {
    return;
  }
  // ...
}

There's a problem though. All of your code has been built on assuming that it could email the user. There is now a hidden dependency (if (!canReceiveEmails) return;) and you don't know all the flows in your app that will be broken by no longer being able to email certain users.

Similar problems arise if you have functions that need to always work for all users. What if someone adds a call to a function that does a policy check? Suddenly your function that must always work for all users starts to fail in production for a subset of users, putting them into an incosistent state.

Solution?

Wouldn't it be great if you could look at a function and see all policy dependencies it had? And you'd know at compile time if your code is policy compliant? That if someone were to add a dependency on a policy to a function that should never require policies, the compiler would tell you?

We can do this with a small abuse of intersection and union types.

The below example uses TypeScript to annotate functions with their policy requirements, allowing the compiler to assist the developer in writing compliant code.

Policies as Types

In the proof of concept below, we'll define a function that sets a cookie on the user but only if:

  1. They've accepted the cookie policy or
  2. They're not an EU user
function setCookie(user: User & Policy<AcceptedCookies | NonEUUser>)

And the TypeScript compiler will not let us call this function unless the user passes the required policy checks.

Full, working, proof of concept below.

// Create the `Policy` interface to represent an evaluated policy
interface Policy<T> {
  __policy: T
}

// Type to represent the cookie consent policy
interface AcceptedCookies {
  acceptedCookies: true
}

// Type to represent non eu citizenship status
interface NonEUUser {
  nonEUUser: true
}

// Base user type, no policies
interface User {
  id: number
}

// Function to set a cookie. We can't set a cookie unless:
// a. The user accepted our cookie policy
// or b. the user is not in the EU
function setCookie(user: User & Policy<AcceptedCookies | NonEUUser>) {
  // do cookie magic
}

// Our main application
async function app(user: User) {
  setCookie(user); // Compile error!!!! We need to pass the cookie or non-eu user policy first!!

  // Check our policies
  const [userWithCookieCheck, userWithNonEUCheck] = await Promise.all([checkCookiePolicy(user), checkNonEUUserPolicy(user)]);

  const evaluatedUser = userWithCookieCheck || userWithNonEUCheck;
  if (!evaluatedUser) {
    alert('This app requires cookies! Please accept our cookie policy');
    return;
  }

  // We checked the policies required by setCookie so we can call it now with no issues :)
  setCookie(evaluatedUser);

  // nonSensitive didn't require any policies. We can call it with whatever we like.
  nonSensitive(evaluatedUser);

  // We've already done policy evalation up front. We can continue to call sensitive methods with no issues now.
  if (userWithNonEUCheck) {
    logNonEUUserData(userWithNonEUCheck);
  }
}

function nonSensitive(user: User) {
  // this method doesn't do anything special with users and thus has no policy restrictions.

  // If someone were to add a dependency on a method that has policy restrictions then we would get type errors.
  // e.g.,

  // calling
  // setCookie(user);
  // will throw a compile time error.
}

function logNonEUUserData(user: User & Policy<NonEUUser>) {
  // We now know that this function will only ever log for non EU users. If we try to call it with EU users we will get
  // compile time errors.
}

// This is a function to do the policy evaluation at runtime.
async function checkCookiePolicy(user: User): Promise<(User & Policy<AcceptedCookies>) | null> {
  let policyPassed = true; // call your policy checking code
  if (policyPassed) {
    return {
      ...user,
      __policy: { acceptedCookies: true },
    };
  }

  return null;
}

// Same as above.
async function checkNonEUUserPolicy(user: User): Promise<(User & Policy<NonEUUser>) | null> {
  let policyPassed = true; // call your policy checking code
  if (policyPassed) {
    return {
      ...user,
      __policy: { nonEUUser: true },
    };
  }

  return null;
}

We can even get more complicate with our policy types:

interface Bar { bar: true }

// Can not call Foo unless User passes (AcceptedCookies AND NonEUUser) OR Bar
function foo(user: User & Policy<(AcceptedCookies & NonEUUser) | Bar>) {
}

Play with the code and view compile time enforcement here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment