DEV Community

Kevin Cox
Kevin Cox

Posted on • Originally published at kevincox.ca on

Lying to TypeScript for Fun and Profit

I’ve been writing a lot more TypeScript recently and have been pleasantly surprised. It mostly negates the downsides of JavaScript and creates a fairly pleasant programming environment.

One interesting thing about TypeScript is that it feels that there is a weaker bond between the compile-time types and run-time types than in most other programming languages. For example TypeScript considers the following code perfectly legitimate.

let data = JSON.parse("{}");
data.achieveWorldPeace();

This is because JSON.parse returns any. This is a magical type that causes TypeScript to assume that whatever you do is correct. So TypeScript assumes that data has a method achieveWorldPeace that can be called with no arguments to return any.

You will commonly see code such as:

let res = await fetch("/api.json");
let data: ApiResponse = await res.json();

This is better. Because now you have “diffused” the any and TypeScript will no longer let you call data.achieveWorldPeace(). However, nothing actually checked that data implements ApiResponse. The following code is perfectly fine.

let data: {bar: string} = JSON.parse(`{"foo": 7}`);

Of course this isn’t specific to TypeScript. Many languages have backdoors to their type system. The explicit way to do this in TypeScript is v as ApiResponse. Other languages have similar escapes; Rust has unsafe { std::mem::transmute::<ApiResponse>(v) } and C++ has reinterpret_cast<ApiResponse>(v). The difference is that in those two languages casting is very restricted, most casts will cause undefined behaviour. In TypeScript the types don’t affect the runtime code at all. This means that as long as the code happens to be correct subverting the type system can be relatively safe and useful.

New Types

The most common use I see is implementing “new type” aliases. Since TypeScript is structurally typed it can be difficult to ensure validation via the type system. For example in Rust I will often use the “New Type Struct” pattern for validated values.

pub struct HtmlEscaped(String);

pub fn escape(text: &str): HtmlEscaped {
    HtmlEscaped(escape_html(text))
}

// Warning! Unsafe! Can cause XSS vulnerabilities
// if `text` is not in fact trustworthy.
pub fn write_trusted_raw_html(text: &str) {
    println!("{text}");
}

pub fn write_text(text: HtmlEscaped) {
    // We trust this text because it was escaped.
    write_trusted_raw_html(&text.0)
}

You can see that this makes it much harder for the user to accidentally write unescaped HTML and cause a XSS vulnerability. They are forced to either escape their HTML so that they can call write_text or use the raw function with a scary name that will make them (and their code reviewer) double check that the value is indeed trustworthy.

Doing something similar in TypeScript is not straightforward because in a structural typed language types are just aliases.

interface RawText { text: string };
interface EscapedHtml { text: string };

function ohno(text: RawText): EscapedHtml {
    return text; // These are the same type.
}

Being Honest

The natural way to do this is add some sort of marker that the text has been escaped.

interface RawText { text: string };
interface EscapedHtml extends RawText { __properlyEscapedMarker: true };

function conversionBlocked(text: RawText): EscapedHtml {
    // ERROR: Property '__properlyEscapedMarker' is missing
    // in type 'RawText' but required in type 'EscapedHtml'
    return text;
}

Then you can provide a small helper to do the escaping.

function escapeText(text: RawText): EscapedHtml {
    return {
        text: textToHtml(text),
        __properlyEscapedMarker: true,
    }
}

Now it will be very difficult to accidentally pass a RawText where an EscapedHtml is expected.

Small Fibs

But what if you didn’t want to actually update the type. Maybe the code is performance-sensitive or you don’t want the value to be visible at runtime. You can work around this by casting the value. You can think of this as “blessing” the object into the verified/sanitized type.

interface RawText { text: string };
interface EscapedHtml extends RawText { __properlyEscapedMarker: never };

function escapeText(text: RawText): EscapedHtml {
    let escaped: RawText = {
        text: textToHtml(text.text),
    };
    return escaped as EscapedHtml;
}

Now the __properlyEscapedMarker property is never actually added to the object at runtime. It exists only in the type system to avoid accidental conversions.

Symbols

You can make this slightly more robust by using a unique symbol which allows creating a unique property. This allows controlling the ability to create these objects with regular visibility rules.

interface EscapedHtml extends RawText { readonly EscapedHtmlTag: unique symbol };

Of course this has limited benefit as anyone anywhere can still do foo as EscapedHtml. It only prevents accidental collisions which are unlikely to be a problem in practice.

Optimization

In some cases it is desirable to avoid the extra object wrapper. Just use a union type if your “base” type isn’t an object.

type EscapedHtml = string & { readonly EscapedHtmlTag: unique symbol };

Now at runtime you are just passing around a string, the checks only exist at compile time.

Hiding the Implementation

For the EscapedHtml type it is probably fine for the user to use the value as a string. However imagine that you wanted to hide this implementation. This can create a unique type that can only be used in the “correct” places (and places that accept any or unknown).

export interface ItemId { readonly ItemIdTag: unique symbol };

function wrapItemId(id: string): ItemId {
    return id as unknown as ItemId;
}

function unwrapItemId(id: ItemId): string {
    return id as unknown as string;
}

export function listIems(): ItemId[] {
    return listItemsRaw().map(id => wrapItemId(id));
}

export function getItem(id: ItemId): Item {
    return getItemRaw(unwrapItemId(id));
}

This pattern completely hides the details of ItemId from the type system. So users external to this module are unable to (safely) create or to use them in many ways. (Again types that take any or unknown like console.log() will still be able to accept them). For example this could be useful to help control how the ID is presented to the user or embedded in URLs.

I highly recommend creating a pair of functions like wrapItemId and unwrapItemId to keep the unsafety isolated. But don’t export them so that only the relevant module can create instances of this type without as.

Unfortunately many operations are allowed on all types, so `${id}` will still work. But it at last prevents passing to any functions or having non-universal methods called on it.

Note: If you really want to prevent method calls you can union with undefined or null. However I wouldn’t recommend this as it produces bad error messages.

export type ItemId = { readonly ItemIdTag: unique symbol } & undefined;

In most cases typescript calls this type undefined rather than ItemId.

Summary

If you want to create a unique type use the following:

export interface MyType { readonly MyTypeTag: unique symbol };

I have found this to give consistently good error messages and provide solid protection against errors.

If you want to “extend” an existing type BaseType with a tag do this:

export type MyType = BaseType & { readonly MyTypeTag: unique symbol };

Top comments (0)