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
Top comments (1)
Thank you! great post :)