My personal philosophy regarding logging is that one should only log things that are contextually important - this means you have to actively think about what to log, and not simply dump your whole process data and call it a day. This not only reduces storage usage, but also makes complying with data privacy laws and payment security standards less complicated.

But guess what? Humans make mistakes. And that made me wonder… Could we protect ourselves against this kind of mistake by leveraging the TypeScript type system? It turns out we can.

Setting the stage

Let’s start with a small code snippet - imagine this was extracted from a system that processes payments:

declare function capturePayment(creditCard: CreditCard): void;

function logError(message: string, context: object): void {
    // Sends error to third-party service
}

type CreditCard = {
  brand: string;
  creditCardNumber: string;
  cvv: number,
};

const creditCard = {
  brand: "Visa",
  creditCardNumber: "4111 1111 1111 1111",
  cvv: 123,
} satisfies CreditCard;

try {
    capturePayment(creditCard);
} catch (error) {
    logError("Something went wrong", { creditCard });
}

If you look at the code closely, you will see that, when the call to capturePayment fails, we end up logging the raw creditCard data to a third-party service. That is a grave breach of data security and definitly not PCI compliant.

It Is NoT tHe OnLy SoLuTiOn, BuT a SoLuTiOn 🤖

How can we ensure that we do not accidentally pass creditCard (or one of its sensitive properties) to the logError function?

First, we need a way to mark the sensitive data as such. While Typescript is structurally typed by default (i.e. if two types have the same shape, the compiler considers them same), we can simulate nominal typing (i.e. the compiler cares about the type name) by using “type branding” - this adds metadata for the compiler without changing the underlying runtime value. We start by defining a custom type to wrap our original declarations:

type Sensitive<T> = T & { readonly __brand: unique symbol };

We then use it on the CreditCard type:

type CreditCard = {
  brand: string;
  creditCardNumber: Sensitive<string>;
  cvv: Sensitive<number>,
};

const creditCard = {
  brand: "Visa",
  creditCardNumber: "4111 1111 1111 1111" as Sensitive<string>,
  cvv: 123 as Sensitive<number>,
} satisfies CreditCard;

Now the compiler knows these are not just random strings, but something special. Next, we must define a type constraint to check whether a type or one of its members contains a Sensitive value. I was feeling lazy, so I asked my AI companion to generate it. This is what it came back with:

type HasSensitive<T> = T extends any
  ? T extends Sensitive<any>
    ? true
    : T extends (infer U)[]
      ? HasSensitive<U>
      : T extends object
        ? true extends { [K in keyof T]: HasSensitive<Exclude<T[K], undefined>> }[keyof T]
          ? true
          : false
        : false
  : never;

I am neither a TypeScript specialist nor someone who blindly trusts my cranker friend, so we need to break this down to understand it better:

  • T extends any means nothing by itself, but in the context of conditional types, this makes the type become distributive when given an union type.
  • T extends Sensitive<any> is kind of self-explanatory - if a type extends Sensitive, it is sensitive.
  • T extends (infer U)[] checks if T is an array, and infer U extracts the element type of the array.
  • HasSensitive<U> checks if the previously extracted element type of the array U is sensitive (via recursion).
  • T extends object checks if T is not a primitive.
  • true extends { [K in keyof T]: HasSensitive<Exclude<T[K], undefined>> }[keyof T] maps over every property of T (which we already confirmed is an object, not a primitive). It strips out undefined types from type unions, and recursively passes each property back into HasSensitive. Finally, it checks if any of those properties evaluated to `true.

Apart from the last item, it is not that complicated, right?

Now, let’s define a helper type to fail compilation when a type contains a Sensitive value:

type EnforceNonSensitive<T> = true extends HasSensitive<T>
  ? "Error: Sensitive data should not be logged"
  : unknown;

Lets apply the helper to the logError function:

function logError<T extends object>(
  message: string, 
  context: T & EnforceNonSensitive<T>
) {
    // Sends error to third-party service
}

The final code looks like this:

type Sensitive<T> = T & { readonly __brand: unique symbol };

type HasSensitive<T> = T extends any
  ? T extends Sensitive<any>
    ? true
    : T extends (infer U)[]
      ? HasSensitive<U>
      : T extends object
        ? true extends { [K in keyof T]: HasSensitive<Exclude<T[K], undefined>> }[keyof T]
          ? true
          : false
        : false
  : never;

type EnforceNonSensitive<T> = true extends HasSensitive<T>
  ? "Error: Sensitive data should not be logged"
  : unknown;

function logError<T extends object>(
  message: string, 
  context: T & EnforceNonSensitive<T>
) {
    // Sends error to third-party service
}

declare function capturePayment(creditCard: CreditCard): void;

type CreditCard = {
  brand: string;
  creditCardNumber: Sensitive<string>;
  cvv: Sensitive<number>,
};

const creditCard = {
  brand: "Visa",
  creditCardNumber: "4111 1111 1111 1111" as Sensitive<string>,
  cvv: 123 as Sensitive<number>,
} satisfies CreditCard;

try {
    capturePayment(creditCard);
} catch (error) {
    logError("Something went wrong", { creditCard });
}

If we run the type checker - with Vite+, vp check --no-fmt --no-lint - we now get the following error:

Argument of type '{ creditCard: { brand: string; creditCardNumber: Sensitive<string>; cvv: Sensitive<number>; }; }' is not assignable to parameter of type '{ creditCard: { brand: string; creditCardNumber: Sensitive<string>; cvv: Sensitive<number>; }; } & "Error: Sensitive data should not be logged"'.
  Type '{ creditCard: { brand: string; creditCardNumber: Sensitive<string>; cvv: Sensitive<number>; }; }' is not assignable to type '"Error: Sensitive data should not be logged"'.

Once we remove the Sensitive values from the context being passed to logError, the error message disappears. Mission accomplished. 🫡

What about runtime guarantees?

Unlike science, I have not gone too that far. However, I imagine we could have a class Sensitive<T> to wrap sensitive values, and have some instanceof Sensitive checks inside of logError, which would be a terrible idea if we have deeply nested objects.