loading...

Notes on TypeScript: Phantom Types

busypeoples profile image A. Sharif ・6 min read

Introduction

These notes should help in better understanding TypeScript and might be helpful when needing to lookup up how leverage TypeScript in a specific situation. All examples are based on TypeScript 3.2.

Phantom Types

In the following "Notes on TypeScript" we will talk about Phantom Types.
To better understand Phantom Types, we will build examples a long the way, that should display where using them can be useful.

"A phantom type is a parametrised type whose parameters do not all appear on the right-hand side of its definition..."
Haskell Wiki, PhantomType

Taking a look at the above definition from the Haskell wiki, we understand that phantom types are parametrised types where not all parameters appear on the right-hand side. Let's try to see if we can implement a similar example in TypeScript.

type FormData<A> = string;

FormData is a phantom type as the A parameter only appears on the left side.

Next, we want to enable a library user to create a FormData type. What we also want is to restrict the type in certain parts of the library. For example we want to be able to differentiate between Validated und Unvalidated form data. So let's add two type definitions Validated and Unvalidated.

type Unvalidated = {_type: "Unvalidated"};
type Validated = {_type: "Validated"};

Next, let's implement a FormData type that makes it impossible for consumers of the form library to override the value type definition. In this specific case we will define value with a never type.

type FormData<T, D = never> = {value: never} & T;

Now we would want to expose a function that receives a string and returns an unvalidated FormData type.

type makeFormData = (a: string) => FormData<Unvalidated>;

And maybe we want to add an upperCase function that does exactly that, take an unvalidated FormData and returns an unvalidated FormData.

type upperCase = (a: FormData<Unvalidated>) => FormData<Unvalidated>;

Let's also add a validate definition that either returns the validated input or null.

type Validate = (a: FormData<Unvalidated>) => FormData<Validated> | null;

Finally let's add a function that processes our validated data:

type Process = (a: FormData<Validated>) => FormData<Validated>;

Now that we have defined our form helper functions, let's see how we can implement these definitions to ensure that the actual form value is always hidden from developer land.

export const makeFormData: MakeFormData = value => {
  return { value } as FormData<Unvalidated>;
};

If we recall our makeFormData function accepts a string and returns a FormData<Unvalidated>, it's important to note, that this should be the only way to create this type. Developer land is prevented from defining the value because it's defined as a never type.
Once this type has been created, developers can use the returned value to validate or uppercase the value.

Let's see how we can implement the upperCase and validate functions.

export const upperCase: UpperCase = data => {
  const internalData = data as InternalUnvalidated;
  return { value: internalData.value.toUpperCase() } as FormData<Unvalidated>;
};

export const validate: Validate = data => {
  const internalData = data as InternalUnvalidated;
  if (internalData.value.length > 3) {
    return { value: internalData.value } as FormData<Validated>;
  }
  return null;
};

Looking at the above two functions, there is one important aspect we can observe. We need to internally cast the provided input. But what is InternalUnvalidated?

type InternalUnvalidated = Unvalidated & {
  value: string;
};

type InternalValidated = Validated & {
  value: string;
};

What we are doing here, is defining an internal representation of our data, that is hidden away from developers using the library. We are stating that value is a string in this case.
process can be written in the same way as the above functions, only that we would be casting to a InternalValidated type, as we're expecting a FormData<Validated> type.

export const process : Process = (data: FormData<Validated>) => {
  const internalData = data as InternalValidated;
  // do some processing...
  return data; // cast to FormData<Validated>
}

We have implemented phantom types and can verify that our library works as expected:

import { makeFormData, validate, upperCase, process } from './phantomTypes'

const initialData = makeFormData("test");
const validatedData = validate(initialData);

// validate("hello") // Type '"hello"' is not assignable to type '{value: never}'
// validate({value: "hello"}) // Type 'string' is not assignable to type 'never'

if (validatedData !== null) {
  // validate(validatedData); // Error! Type '"Validated"' is not assignable to Type '"Unvalidated"'
  upperCase(initialData);
  // upperCase(validatedData) // Error! Type '"Validated"' is not assignable to Type '"Unvalidated"'
  process(validatedData);
  // process(initialData); // Error! Type '"Unvalidated"' is not assignable to Type '"Validated"'
}

We could also create our own PhantomType type and abstract having to manually take care of handling _type:

type PhantomType<Type, Data> = {_type: Type} & Data;

// use this type helper to create an UnvalidatedData type
type UnvalidatedData = PhantomType<"Unvalidated", {value: string}>

At this point, we should have a basic understanding of how phantom types can be implemented in TypeScript now. Checkout the full example here.

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Posted on by:

busypeoples profile

A. Sharif

@busypeoples

Focusing on quality. Software Development. Product Management. https://twitter.com/sharifsbeat

Discussion

markdown guide