An exploration of opaque types in TypeScript and a refined implementation that provides stronger guarantees for handling sensitive information.
Introduction
Technical textbooks often provide foundational knowledge, but real-world applications sometimes require refining these concepts. Recently, while reading “Programming TypeScript” by Boris Cherny, I encountered an implementation of opaque types (also known as branded or nominal types) that could be improved for certain use cases, particularly when handling sensitive data like encrypted strings.
Opaque types in TypeScript serve an important purpose: they help you create more specific types for better type checking. Instead of using primitive types like string
or number
for everything, you can create distinct types that carry semantic meaning. This approach leads to more robust code and helps prevent logical errors that type checking wouldn’t otherwise catch.
Understanding Opaque Types
In TypeScript, all type checking is structural by default. This means that if two objects have the same shape (the same properties and methods), TypeScript considers them compatible, regardless of what you named their types. While this approach is flexible, it can lead to issues when you need to distinguish between values that have the same structure but different semantic meanings.
For example, consider a simple case where you have user IDs and order IDs, both represented as numbers:
type UserId = number;
type OrderId = number;
function processUser(userId: UserId) {
// Process user...
}
const orderId: OrderId = 12345;
processUser(orderId); // No error! TypeScript sees both as numbers
This is where opaque types come in. They allow you to create types that are structurally identical to existing types but are treated as distinct by the type checker.
The Textbook Implementation
The textbook implementation of opaque types often looks something like this:
type Encrypted<T extends string> = T & { __opaque_type: unique symbol };
function encrypt(text: string): Encrypted<string> {
// Actual encryption logic here
return someEncryptedResult as Encrypted<string>;
}
function decrypt(encrypted: Encrypted<string>): string {
// Actual decryption logic here
return decryptedValue;
}
This approach uses the intersection type operator (&
) to combine a string with an object type that has a property with a unique symbol. Since no actual JavaScript object can have a property with a unique symbol
type (which is only available at the type level), this effectively creates a new type that TypeScript treats differently from the original string type.
The problem with this implementation becomes apparent when you use it:
const encrypted = encrypt("sensitive data");
let regular: string;
// This shouldn't be allowed, but it is
regular = encrypted; // No error!
Because Encrypted<string>
is defined as string & { __opaque_type: unique symbol }
, it’s still considered a subtype of string
. This means you can assign an Encrypted<string>
to a variable of type string
, which defeats the purpose of having a distinct type in the first place.
A Refined Implementation
To address this issue, I’ve refined the implementation to create a true nominal type that cannot be implicitly converted to its base type:
type Encrypted<T extends string> = { __opaque_type: T & symbol };
function encrypt(text: string): Encrypted<string> {
// Actual encryption logic here
return someEncryptedResult as unknown as Encrypted<string>;
}
function decrypt(encrypted: Encrypted<string>): string {
// Actual decryption logic here
return decryptedValue;
}
In this implementation, Encrypted<T>
is defined as an object type with an __opaque_type
property, rather than an intersection type. This makes Encrypted<string>
a completely different type from string
, not a subtype. The JavaScript runtime representation is still just a string, but TypeScript’s type system treats it as something entirely different.
A Practical Example: Redacted Text
Let’s explore a practical example using a similar concept: redacted text. We want to create a type Redacted<string>
that represents redacted text (like passwords or other sensitive information), ensuring the type system prevents us from accidentally using redacted text where plain text is expected.
// `never` type is not used because have to make sure type parameter `T` is used
type Redacted<T extends string> = T & { readonly __opaque_type: unique symbol };
function Redacted<T extends string>(_t: T): Redacted<T> {
return "#".repeat(_t.length) as unknown as Redacted<T>;
}
type User = {
name: string,
redactedPassword: Redacted<string>,
}
type PrivateUser = {
name: string,
// For illustration purposes only
plainPassword: string,
}
const redactedBye = Redacted<string>("bye");
console.log(redactedBye, typeof redactedBye) // "###", "string"
// Not possible to construct the type by hand
const impossibleRedactedPassword: Redacted<string> = {
__opaque_type: "bye" // Type 'string' is not assignable to type 'never'.(2322)
}
// Only possible to construct by type assertion
const constructedRedactedPassword: Redacted<string> = Redacted("bye");
const user: User = {
name: "hey",
redactedPassword: constructedRedactedPassword,
}
const plainUser: PrivateUser = {
name: "hey",
// PROBLEM: Mistake is not caught because `T` is a subtype of `T & { readonly __opaque_type: unique symbol }`
plainPassword: constructedRedactedPassword,
}
Let’s analyze what’s happening in this code:
- We define a
Redacted<T>
type using the textbook intersection approach, which creates a subtype of the original type. - We create a function
Redacted()
that takes a string and returns a string of hash characters (#
) with the same length, cast to ourRedacted<T>
type. - We demonstrate that at runtime, a
Redacted<string>
is just a regular string (with type “string”). - We show that you can’t manually construct a value of type
Redacted<string>
by creating an object with an__opaque_type
property. - We create a user object with a redacted password, which works as expected.
- Finally, we demonstrate the problem: we can assign a
Redacted<string>
to a variable of typestring
without any type error.
The last point is crucial. The type system should prevent us from using a redacted password as a plain password, but with the textbook implementation, it doesn’t.
The Improved Implementation
Now, let’s see how our refined implementation fixes this issue:
type Redacted<T extends string> = { __opaque_type: T & symbol };
function Redacted<T extends string>(_t: T): Redacted<T> {
return "#".repeat(_t.length) as unknown as Redacted<T>;
}
type User = {
name: string,
redactedPassword: Redacted<string>,
}
type PrivateUser = {
name: string,
plainPassword: string,
}
const redactedBye = Redacted<string>("bye");
console.log(redactedBye, typeof redactedBye) // "###", "string"
const constructedRedactedPassword: Redacted<string> = Redacted("bye");
const user: User = {
name: "hey",
redactedPassword: constructedRedactedPassword,
}
const plainUser: PrivateUser = {
name: "hey",
// ERROR: Type 'Redacted<string>' is not assignable to type 'string'
plainPassword: constructedRedactedPassword,
}
With this improved implementation, TypeScript correctly identifies that we’re trying to use a Redacted<string>
where a plain string
is expected. This provides much stronger guarantees about how these types are used in our code.
Practical Applications
This refined approach to opaque types is particularly valuable in several scenarios:
Encrypted Data
When working with encrypted data, especially in applications that use ORMs to interact with databases, it’s common for encrypted fields to be automatically mapped to string types. Our improved opaque types ensure that you can’t accidentally use these encrypted strings without decrypting them first.
function storeInDatabase(user: User): void {
// Store user in database
}
function displayUser(name: string, password: string): void {
// Display user information
}
const user: User = {
name: "Alice",
encryptedPassword: encrypt("p@ssw0rd")
};
storeInDatabase(user); // Fine
// Error: Type 'Encrypted<string>' is not assignable to type 'string'
displayUser(user.name, user.encryptedPassword);
// Correct:
displayUser(user.name, decrypt(user.encryptedPassword));
API Identifiers
When working with external APIs, you often need to deal with different types of identifiers that might all be strings or numbers underneath. Using opaque types helps ensure you don’t mix them up.
type UserId = { __opaque_type: string & symbol };
type OrderId = { __opaque_type: string & symbol };
function fetchUser(userId: UserId): User { /* ... */ }
function fetchOrder(orderId: OrderId): Order { /* ... */ }
const userId = getUserId(); // Returns UserId
const orderId = getOrderId(); // Returns OrderId
fetchUser(userId); // Fine
fetchOrder(orderId); // Fine
fetchUser(orderId); // Error: Type 'OrderId' is not assignable to type 'UserId'
Units of Measure
Opaque types can help prevent confusion between different units of measure:
type Meters = { __opaque_type: number & symbol };
type Feet = { __opaque_type: number & symbol };
function calculateAreaInSquareMeters(length: Meters, width: Meters): number { /* ... */ }
const lengthInMeters: Meters = getLength(); // Returns Meters
const widthInFeet: Feet = getWidth(); // Returns Feet
// Error: Type 'Feet' is not assignable to type 'Meters'
calculateAreaInSquareMeters(lengthInMeters, widthInFeet);
Opaque types are a powerful feature in TypeScript that allow you to create more specific and semantically meaningful types. While the textbook implementation using intersection types works for basic cases, it falls short when you need to prevent implicit conversions between an opaque type and its base type.
By refining the implementation to use an object type with a branded property, rather than an intersection type, we can create true nominal types that provide stronger guarantees. This approach is particularly valuable when working with sensitive data, such as encrypted strings, where accidentally using the raw value could lead to security issues.
TypeScript continues to evolve, and techniques like this demonstrate how developers can push the boundaries of the type system to create more robust and secure applications. As you work with TypeScript, consider whether your use cases might benefit from this refined approach to opaque types.
You can experiment with these examples in the TypeScript Playground to see the differences between the two implementations and understand how they affect type checking in your code.
Comments