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
Comments
Such a type would be welcome. It should be a new type (we can eventually deprecate |
Please do not deprecate |
@ethanresnick Is there any way |
@sindresorhus Yes, I think that use case doesn't strictly require 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 At my work, we have a type for that called 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 If you compare the resulting types, though, between
string & { [tag]: { "JSON": void } } & { [meta]: { hello: 'world' } } whereas 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 From an implementation perspective, this is very simple and backwards compatible: it's just taking the current 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 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); |
Thanks for explaining. Makes sense to me to support this. |
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>
I'm pretty sure that's exactly what we want — and people who want invariant behavior can get that using the |
Ok, turns out I had a bit of time, so I threw up a quick PR in #723. |
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:
That way, I can pass a
SubsystemId
to any utility function I have that takes aUrl
, too.If you try this sort of thing in type-fest today, though, you run into problems:
The root of the issue is that:
the Typescript compiler has some built-in hacks that tell it not to reduce
string & Tagged<"NormalizedPath">
tonever
, 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;however, as soon as the type becomes
string & Tagged<"NormalizedPath"> & Tagged<"AbsolutePath">
, those hacks are insufficient and the type is immediately reduced tonever
, 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:which reduces to
never
, you represent it as:which is equivalent to:
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 withstring
.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:The
Token
parameter onOpaque
is currently unconstrained; with this implementation, it would have to extendstring | number | symbol
. I don't actually think that limitation is a big problem, becauseToken
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 aunique symbol
type as theToken
is sufficient. However, this example from the current documentation would no longer be supported:One would instead need to do:
or, for the super-paranoid:
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 thatUnwrapOpaque
could be made to work on both the currentOpaque
type and the new version. The only potential downsides to that approach are: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., anOpaque<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 existingTagged
type (which isn't exported so should be fine to rename) to something likeTagContainer
.Existing
Opaque
types that use aToken
which isn't a string, number, or symbol couldn't interoperate with the new tagged types.All the types would be a bit more complicated, to support interoperability between the old and new form of tagged types.
The text was updated successfully, but these errors were encountered: