Phantom types in Typescript
🏠 Go homePhantom 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.
- opened
- opened in transaction
- closed
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.