Phantom types in Typescript

🏠 Go home
Typescript 2022-08-16

Phantom type is a type parameter (type variable in generic types) which is not used in the type declaration. For example:

type InputValue<A> = { value: string };

The parameter A must be provided to construct a type from InputValue type constructor but it is not used anywhere on the right-hand side of the type declaration. Is that even useful then? Meh, it's pretty close to be useful. The problem is that during type checking, Typescript will evaluate InputValue<X> and InputValue<Y> (for arbitrary types X and Y) are the same types because the A doesn't structurally change the type. To solve that we can actually add the type parameter to the declaration of the type InputValue.

type InputValue<A> = { readonly _A: A, value: string };

With this definition, InputValue<A> a InputValue<B> are in general different types for the type checker. The type parameter A is not really phantom in that case because it's being used on the right-hand side. But the intention is to never access the _A property and only use it for type-checking purpose. Let's see an example how to use that in practice.

Let's say we are modelling a type for database client.

type Result = unknown;

type WhateverUnderlyingDatabaseLibrary = {
  connect: () => Promise<void>;
  query: (sql: string) => Promise<Result>;
}

type DatabaseClient<S> = { readonly _S: S, readonly client: WhateverUnderlyingDatabaseLibrary };

If the client encapsulates a null-pool database connection it can be in three states.

Let's model these states.

type Closed = { readonly ConnectionNotOpened: unique symbol };
type Opened = { readonly ConnectionNotOpened: unique symbol };
type InTransaction = { readonly ConnectionNotOpened: unique symbol };

Closed, Opened and InTransactino are not intended to be instantiated in the term-level code. They are there just for the purpose of type-level enumeration for the S type parameter of the DatabaseClient<S> type.

Now, we will create low-level functions operating on the DatabaseConnection. These functions can change the client's state but these changes will be tracked in the S type variable.

connect

The connect function takes a DatabaseClient<Closed> and returns DatabaseClient<Opened> (in reality, it might raise an error as well but let's pretend it always succeeds). The problem is we need to return a db instance converted into DatabaseClient<Opened> on the type level. For that, we will create a simple utility function which only purpose is changing the type variable S.

const unsafeConvert = 
  <A extends DatabaseClientState, B extends DatabaseClientState>(fa: DatabaseClient<A>): DatabaseClient<B> => 
    fa as unknown as DatabaseClient<B>;

With this function, the implementation is pretty simple.

const openConnection = async (db: DatabaseClient<Closed>): Promise<DatabaseClient<Opened>> => {
  await db.client.connect();
  return unsafeConvert(db);
}

beginTransaction, commitTransaction, rollbackTransaction

Let's create begin, commit and rollback for handling transactions.

const beginTransaction = async (db: DatabaseClient<Opened>): Promise<DatabaseClient<InTransaction>> => {
  await db.client.query('BEGIN');
  return unsafeConvert(db);
}

const commitTransaction = async (db: DatabaseClient<InTransaction>): Promise<DatabaseClient<Opened>> => {
  await db.client.query('COMMIT');
  return unsafeConvert(db);
}

const rollbackTransaction = async (db: DatabaseClient<InTransaction>): Promise<DatabaseClient<Opened>> => {
  await db.client.query('ROLLBACK');
  return unsafeConvert(db);
}

query

Function query is more interesting because we want it to work with Opened client but also with client InTransaction. We can deal with that using a type parameter with upper bound InTransaction | Opened. Also, notice the return type is tuple [Result, DatabaseClient<T>] because we need to keep track of database client and propagate the result of the query at the same time.

const query = async <T extends InTransaction | Opened>(sql: string, db: DatabaseClient<T>): Promise<[Result, DatabaseClient<T>]> => {
  const result = await db.client.query(sql);
  return [result, unsafeConvert(db)];
}

Now, there is a problem that we can actually do await query<Opened>('BEGIN', client). We could probably deal with that by creating a custom type for the sql query string and restricting its creation in a way that these queries are impossible. I will not deal with this problem in here.

Implications

With functions constructed above we made a set of non-sensical programs working with DB not compilable! For example, it shouldn't be possible to trigger rollback if we didn't begin a transaction.

const exampleProgram = async (db: DatabaseClient<Closed>) => {
  const openedDb = await openConnection(db);
  rollbackTransaction(openedDb);
};

The type checker will raise the following error.

Argument of type 'DatabaseClient<Opened>' is not assignable to parameter of type 'DatabaseClient<InTransaction>'.
  Type 'Opened' is not assignable to type 'InTransaction'.
    Types of property 'ConnectionNotOpened' are incompatible.
      Type 'typeof ConnectionNotOpened' is not assignable to type 'typeof ConnectionNotOpened'. Two different types with this name exist, but they are unrelated.

Also, say good bye to accidental SQL queries on a closed connection!

const exampleProgram2 = async (db: DatabaseClient<Closed>) => {
  const result = await query('SELECT * FROM test;', db);
};

Because the compiler will catch these errors for you.

Argument of type 'DatabaseClient<Closed>' is not assignable to parameter of type 'DatabaseClient<Opened | InTransaction>'.
  Type 'Closed' is not assignable to type 'Opened | InTransaction'.
    Type 'Closed' is not assignable to type 'InTransaction'.
      Types of property 'ConnectionNotOpened' are incompatible.
        Type 'typeof ConnectionNotOpened' is not assignable to type 'typeof ConnectionNotOpened'. Two different types with this name exist, but they are unrelated.