Skip to content

Support composable tagged types #665

Closed
@ethanresnick

Description

@ethanresnick

Type-fest's current implementation of opaque types is great for attaching one tag to a type. However, it does not support attaching more than one tag to the same type, which is often useful to represent multiple validations that have been performed on a value, or multiple "aspects" of how it can be interpreted.

For example, the Typescript discussions about tagged types use the example of a string that can be an absolute path, a normalized path, or both. Similarly, in my code, I have a tagged type representing URLs and a tagged type representing the ids used by a particular sub-system, but these subsystem ids happen to also be URLs. Therefore, I'd like to have:

type Url = Opaque<string, "Url">;
type SubsystemId = Opaque<Url, "SubsystemId">;

// NB: SubsystemId above is ultimately the same as:
// string & Tagged<"Url"> & Tagged<"SubsystemId">

That way, I can pass a SubsystemId to any utility function I have that takes a Url, too.

If you try this sort of thing in type-fest today, though, you run into problems:

type AbsolutePath = Opaque<string, 'AbsolutePath'>;
type NormalizedPath = Opaque<string, 'NormalizedPath'>;
type PhoneNumber = Opaque<string, 'PhoneNumber'>;

declare function isAbsolutePath<T extends string>(it: T): it is T & AbsolutePath;
declare function requiresPhoneNumber(it: PhoneNumber): void;

// suppose we already have a value that has one tag attached to its type.
declare const normalizedPath: NormalizedPath;

// now, we want to validate it further, which should apply a second tag and
// let us pass the value to functions requiring that tag
if(isAbsolutePath(normalizedPath)) {
  // this call should fail, because the path is definitely _not_ a phone number;
  // instead, it succeeds because the type of `normalizedPath` has been 
  // reduced to `never`, rather than `NormalizedPath & AbsolutePath`
  requiresPhoneNumber(normalizedPath);
}

The root of the issue is that:

  1. the Typescript compiler has some built-in hacks that tell it not to reduce string & Tagged<"NormalizedPath"> to never, even though there's no runtime value that can actually be both a string and an object w/ a symbol-named property, which is what that type requires;

  2. however, as soon as the type becomes string & Tagged<"NormalizedPath"> & Tagged<"AbsolutePath">, those hacks are insufficient and the type is immediately reduced to never, as shown here. The compiler sees { [tag]: "NormalizedPath" } & { [tag]: "AbsolutePath" } and determines (correctly) that the strings in the [tag] property cannot both be satisfied.

The way to work around this, as described here, is to use an object type to store all the tags, and use one key per tag in that object. I.e., instead of SubsystemId ultimately being represented like:

string & { [tag]: "Url" } & { [tag]: "SubsystemId" }

which reduces to never, you represent it as:

string & { [tag]: { Url: void } } & { [tag]: { SubsystemId: void } }

which is equivalent to:

string & { [tag]: { Url: void, SubsystemId: void } }

This ends up working very well with the structural nature of Typescript, in that a function that needs its argument to only have one of the tags can still be called with an argument that has more tags (by the normal TS rules for allowing excess object keys); meanwhile, the structure of the type prevents reduction to never, because the { [tag]: { Url: void, SubsystemId: void } } part actually is satisfiable, which is enough for the TS compiler hack to preserve the full type when intersecting with string.

All of this is to say: supporting types with multiple tags is definitely possible, and I'd be happy to put up a PR. UnwrapOpaque can be made to work too. However, representing tagged types like this would be a breaking change, in two ways:

  1. The Token parameter on Opaque is currently unconstrained; with this implementation, it would have to extend string | number | symbol. I don't actually think that limitation is a big problem, because Token is usually a literal string in my experience, and, for people who want even stronger guarantees about the uniqueness/non-assignability of different opaque types, the ability to use a unique symbol type as the Token is sufficient. However, this example from the current documentation would no longer be supported:

    type Person = {
      id: Opaque<number, Person>;
      name: string;
    }; 

    One would instead need to do:

    type PersonId = Opaque<number, "PersonId">; 
    
    type Person = {
      id: PersonId;
      name: string;
    }; 

    or, for the super-paranoid:

    declare const personId: unique symbol;
    type PersonId = Opaque<number, typeof personId>; 
  2. Second, the underlying representation is different enough that type-fest users who've built their own utility types that inspect the current Opaque types might need to update their logic.

So, my questions for you @sindresorhus are: Do you think this multiple tags functionality is valuable? If so, do you think it's acceptable to break the current API in the (imo, relatively small) ways mentioned above?

If those breaks are not acceptable, another option would be to introduce a new type that's basically the same as Opaque, except that it supports multiple tags. I think that UnwrapOpaque could be made to work on both the current Opaque type and the new version. The only potential downsides to that approach are:

  1. We'd need a new name for this new type, and (for a while) there'd be two types in type-fest with similar functionality. Personally, I think having a new name could be a good thing, because the Opaque name is actually kinda misleading: the type is not opaque at all, in that the type of the runtime value is very plainly exposed and usable — i.e., an Opaque<string, 'X'> can be passed to any function that takes a string, which means its "string-ness" is not hidden and can be depended on in code outside the module where the "opaque" type was defined. Given that, I'd be tempted to call the new type "Tagged" and rename the existing Tagged type (which isn't exported so should be fine to rename) to something like TagContainer.

  2. Existing Opaque types that use a Token which isn't a string, number, or symbol couldn't interoperate with the new tagged types.

  3. All the types would be a bit more complicated, to support interoperability between the old and new form of tagged types.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requesthelp wantedExtra attention is needed

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions