Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support composable tagged types #665

Closed
ethanresnick opened this issue Aug 23, 2023 · 7 comments · Fixed by #672
Closed

Support composable tagged types #665

ethanresnick opened this issue Aug 23, 2023 · 7 comments · Fixed by #672
Labels
enhancement New feature or request help wanted Extra attention is needed

Comments

@ethanresnick
Copy link
Contributor

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.

@sindresorhus
Copy link
Owner

Such a type would be welcome. It should be a new type (we can eventually deprecate Opaque if this one turns out to be better). I think the name Tagged or Brand makes sense.

@yoursunny
Copy link

Please do not deprecate Opaque.
The new Tagged type requires the tag to be a PropertyKey.
I have a use case in which an arbitrary type would be used as the tag:
https://github.com/yoursunny/NDNts/blob/9f4ad1258e071ae5e3a9146137b28d31b590e37a/integ/browser-tests/test-fixture/serialize.ts
Thus, I hope both types can be kept.

@sindresorhus
Copy link
Owner

@ethanresnick Is there any way Tagged could support this? If not, we should document it as another difference between the types.

@ethanresnick
Copy link
Contributor Author

ethanresnick commented Oct 18, 2023

@sindresorhus Yes, I think that use case doesn't strictly require Opaque, if I'm understanding it correctly. We have a very similar use case in our code at work, and there's a natural extension to Tagged that I was planning to propose anyway, which would make this even more ergonomic.

Basically, as I understand the use case from @yoursunny, the idea is to have a type that means "this is a string of JSON that, when parsed, will give back a T".

At my work, we have a type for that called JsonOf<T>, which works like this:

declare const meta: unique symbol;
export type JsonOf<T> = Tagged<string, "JSON"> & { readonly [meta]: T };

export function jsonStringify<T extends JsonValue>(it: Readonly<T>) {
  return JSON.stringify(it) as JsonOf<T>;
}

export function jsonParse<T extends JsonOf<unknown>>(it: T) {
  return JSON.parse(it) as (typeof it)[typeof meta];
}

So Opaque isn't strictly needed, although the need for the extra meta: unique symbol is definitely a bit of an ergonomics hit.

If you compare the resulting types, though, between JsonOf and the Value type from @yoursunny's example, they look like this:

JsonOf<{ hello: 'world' }> expands to...

string & { [tag]: { "JSON": void } } & { [meta]: { hello: 'world' } }

whereas Value<{ hello: 'world' }> expands to

string & { [tag]: { hello: 'world' } }

The latter is simpler, though actually looses the ability to know that all such values are strings of JSON.

I think the best approach, though — the one I was going to propose — is to end up with a type like this:

string & { [tag]: { "JSON": { hello: 'world' } } }

This keeps all the composability of Tagged compared to Opaque (i.e., one base type can have multiple "tags"), but extends it to allow each tag to provide arbitrary type-level metadata (i.e., not constrained to be a property key). So, the above type is saying: "this is a string tagged as "JSON", and the JSON tag's metadata includes the type you'll get back when you JSON.parse the string".

From an implementation perspective, this is very simple and backwards compatible: it's just taking the current Tagged type and, where void is currently hardcoded (in MultiTagContainer), making void a default instead (i.e., the default is that a tag has no metadata) while allowing the user to override it.

So, it'd simply be:

type MultiTagContainer<Token extends PropertyKey, TagMetadata> = {
  readonly [tag]: { [K in Token]: TagMetadata };
};

type Tagged<Type, Tag extends PropertyKey, TagMetadata = void> = Type & MultiTagContainer<Tag, TagMetadata>;

Then, @yoursunny's type and my JsonOf type could be unified as simply:

type JsonOf<T> = Tagged<string, "JSON", T>

with no need for the extra meta symbol I had before.

We'd probably also have a helper type that gets the metadata for a tag, something like:

type GetTagMetadata<
  Type extends MultiTagContainer<TagName, unknown>, 
  TagName extends PropertyKey
> = Type[typeof tag][TagName];

Then, the final, complete usage would be:

type JsonOf<T> = Tagged<string, "JSON", T>;

function stringify<T>(it: T) {
  return JSON.stringify(it) as JsonOf<T>;
}

function parse<T extends JsonOf<unknown>>(it: T) {
  return JSON.parse(it) as GetTagMetadata<T, 'JSON'>;
}

const x = stringify({ hello: 'world' });
const parsed = parse(x);

@sindresorhus
Copy link
Owner

Thanks for explaining. Makes sense to me to support this.

@ethanresnick
Copy link
Contributor Author

Awesome :) I can put together a PR at some point, but definitely can't promise a date. Or, if someone else wants to take this, that'd be great! It should be quite straightforward from my comment above; just involves writing new test cases etc.

The only detail worth thinking about is around what the desired assignability semantics should be, based on tag metadata. E.g., with the implementation in my comment above, and having:

type A = Tagged<string, "JSON", MetaA>
type B = Tagged<string, "JSON", MetaB>

A will be assignable to B if (and only if) MetaA is assignable to MetaB.

I'm pretty sure that's exactly what we want — and people who want invariant behavior can get that using the InvariantOf helper type (e.g., type JsonOf<T> = Tagged<string, "JSON", InvariantOf<T>>) — but I haven't thought about it in enough depth/gone through all the use cases to say that with total confidence.

@ethanresnick
Copy link
Contributor Author

Ok, turns out I had a bit of time, so I threw up a quick PR in #723.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request help wanted Extra attention is needed
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants