Attach nominal tags to any type without breaking assignability. Six types that add, read, match and strip tags that live where structural comparison cannot see them.
TypeScript is structural, so this challenge asks you to smuggle nominal information past the structural checker.
Tag<B, T> marks a type B with the string literal T. The marked type has to stay mutually assignable with the original, tags accumulate in order when you tag an already tagged type, and five companion types read them back: GetTags, UnTag, HasTag, HasTags and HasExactTags. The practical use is proving that a value went through the right functions in the right order.
const doA = <T extends string>(x: T) => {
const result = x
return result as Tag<typeof result, 'A'>
}
const doB = <T extends string>(x: T) => {
const result = x
return result as Tag<typeof result, 'B'>
};
const a = doA('foo')
const b = doB(a)
type Check0 = IsTrue<HasTags<typeof b, ['A', 'B']>>Despite the structural typing system in TypeScript, it is sometimes convenient to mark some types with tags, and so that these tags do not interfere with the ability to assign values of these types to each other.
For example, using tags, you can check that some value passes through the calls of the required functions, and in the correct order:
const doA = <T extends string>(x: T) => {
const result = x
return result as Tag<typeof result, 'A'>
}
const doB = <T extends string>(x: T) => {
const result = x
return result as Tag<typeof result, 'B'>
};
const a = doA('foo')
const b = doB(a)
type Check0 = IsTrue<HasTags<typeof b, ['A', 'B']>>Write a function Tag<B, T extends string> that takes a type B other than null and undefined and returns a type labeled with the string literal type T.
The labeled types must be mutually assignable with the corresponding original types:
declare let x: string
declare let y: Tag<string, 'A'>
x = y = xWhen tagging a type already marked with a tag, a new tag must be added to the end of the list of all tags of the type:
type T0 = Tag<{ foo: string }, 'A'>
type T1 = Tag<T0, 'B'>
type Check1 = IsTrue<HasExactTags<T1, ['A', 'B']>>Add some functions to check for type tags.
GetTags<B> retrieves a list of all tags of a type B:
type T2 = Tag<number, 'C'>
type Check2 = IsTrue<Equal<GetTags<T2>, ['C']>>HasTag<B, T extends string> checks if type B is tagged with tag T (and returns true or false):
type T3 = Tag<0 | 1, 'D'>
type Check3 = IsTrue<HasTag<T3, 'D'>>HasTags<B, T extends readonly string[]> checks if type B is tagged in succession with tags from tuple T:
type T4 = Tag<Tag<Tag<{}, 'A'>, 'B'>, 'C'>
type Check4 = IsTrue<HasTags<T4, ['B', 'C']>>HasExactTags<B, T extends readonly string[]> checks if the list of all tags of type B is exactly equal to the T tuple:
type T5 = Tag<Tag<unknown, 'A'>, 'B'>
type Check5 = IsTrue<HasExactTags<T5, ['A', 'B']>>Finally, add type UnTag<B>, which removes all tags from type B:
type T6 = Tag<{ bar: number }, 'A'>
type T7 = UnTag<T6>
type Check6 = IsFalse<HasTag<T7, 'A'>>View on GitHub: https://tsch.js.org/697
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
type GetTags<B> = any
type Tag<B, T extends string> = any
type UnTag<B> = any
type HasTag<B, T extends string> = any
type HasTags<B, T extends readonly string[]> = any
type HasExactTags<B, T extends readonly string[]> = any
/* _____________ Test Cases _____________ */
import type { Equal, Expect, IsTrue } from '../helpers'
/**
* Tests of assignable of tagged variables.
*/
interface I {
foo: string
}
declare let x0: I
declare let x1: Tag<I, 'a'>
declare let x2: Tag<I, 'b'>
declare let x3: Tag<Tag<I, 'a'>, 'b'>
declare let x4: Tag<Tag<IUnlock 150+ medium, hard, and extreme challenges to master advanced TypeScript.
Monthly subscription. Cancel anytime.
The solution in full:
declare const brand: unique symbol
type Brand = typeof brand
type TagBox<Base, Tags extends readonly string[]> = {
[brand]?: Brand | (Brand & [Base, Tags])
}
type IsAnyType<B> = 0 extends 1 & B ? true : false
type IsUnion<T, U = T> = T extends unknown
? [U] extends [T]
? false
: true
: never
type TagsOf<B> =
B extends TagBox<any, infer T extends readonly string[]>
? Brand extends keyof B
? T
: []
: []
type Attach<Base, Tags extends readonly string[]> =
IsAnyType<Base> extends true
? TagBox<Base, Tags>
: [Base] extends [never]
? TagBox<Base, Tags>
: Base & TagBox<Base, Tags>
type StartsWith<
Tags extends readonly string[],
T extends readonly string[],
> = T extends readonly [infer Head, ...infer Rest extends string[]]
? Tags extends readonly [Head, ...infer TagsRest extends string[]]
? StartsWith<TagsRest, Rest>
: false
: true
type Contains<Tags extends readonly string[], T extends readonly string[]> =
StartsWith<Tags, T> extends true
? true
: Tags extends readonly [unknown, ...infer Rest extends string[]]
? Contains<Rest, T>
: false
type GetTags<B> =
IsAnyType<B> extends true
? []
: [B] extends [never]
? []
: IsUnion<TagsOf<B>> extends true
? []
: TagsOf<B>
type Tag<B, T extends string> =
IsAnyType<B> extends true
? Attach<B, [T]>
: [B] extends [never]
? Attach<B, [T]>
: [B] extends [null | undefined]
? B
: Attach<UnTag<B>, [...GetTags<B>, T]>
type UnTag<B> =
IsAnyType<B> extends true
? B
: [B] extends [never]
? B
: [B] extends [TagBox<infer Base, any>]
? Brand extends keyof B
? Base
: B
: B
type HasTag<B, T extends string> = HasTags<B, [T]>
type HasTags<B, T extends readonly string[]> = Contains<GetTags<B>, [...T]>
type HasExactTags<B, T extends readonly string[]> = Equal<GetTags<B>, [...T]>Two constraints pin down the storage. Equal<'x', keyof Tag<{ x: 0 }, 'foo'> & string> says it must not add a string key, so the key is a unique symbol. And Tag<string, 'a'> has to accept a plain string, so the property must be optional, since an optional property the source is missing is always fine. That gives TagBox, and tagging is the intersection Base & TagBox<...>.
The obvious payload, [brand]?: [Base, Tags], fails immediately:
declare let a: string & { [brand]?: [string, ['a']] }
declare let b: string & { [brand]?: [string, ['b']] }
a = b // error: ['b'] is not assignable to ['a']Optional properties are still compared, and two different tag lists are not assignable to each other. The tests demand that every tagged variant of I be assignable to every other one, in both directions. The fix is a property type whose first union member everybody shares:
[object Object]Assignability between unions is checked member by member: each member of the source must be assignable to some member of the target. Brand matches Brand, and Brand & [Base, Tags] is an intersection, so it is assignable to its own constituent Brand. Whatever the tags are, the check passes both ways, while infer can still pull the payload out of the second member.
TagsOf matches B against TagBox<any, infer T> and infers the tuple. On its own that over-matches: {} has no properties, so it is assignable to a type whose only property is optional, and the inference silently falls back to the constraint readonly string[]. The guard Brand extends keyof B asks the sharper question, whether the symbol key is actually there, and turns GetTags<{}> back into [].
// TagsOf<{} & TagBox<{}, ['foo']>> -> ['foo']
// TagsOf<{}> -> [] (no brand key)UnTag uses the same two steps and returns the Base half of the payload, which is why UnTag<Tag<{}, 'foo'>> is exactly {} rather than something with a leftover property. Tag then reads as one line of arithmetic: untag, append the new tag to the existing list, box it up again.
Three inputs break Base & TagBox<...>, and each one is a separate guard:
never & X is never, so the tag would vanish. Attach returns the bare TagBox instead, which still carries Base and Tags in the payload.any & X is any, same problem, same fix. IsAnyType is the standard 0 extends 1 & B probe, which only holds when B is any.null and undefined are meant to reject tags, and TypeScript already does the work: an intersection containing null and an object type reduces to never. Returning B unchanged is both what the specification asks for and what keeps Equal<Tag<null, 'foo'>, null> true.The order matters. [B] extends [never] has to come before the null | undefined check, because never extends everything, and IsAnyType has to come before both.
TagsOf is a distributive conditional, so a union input produces a union of tag lists. If every member carries the same list they collapse into one tuple and there is nothing to do. If they differ, or if one member is untagged and contributes [], the result is a genuine union, and GetTags reports []:
// TagsOf<Tag<{}, 'foo'> | Tag<1, 'foo'>> -> ['foo'] not a union
// TagsOf<Tag<1, 'foo'> | {}> -> ['foo'] | [] union, so []IsUnion detects that by distributing over T while keeping the whole union in U: for a single member [U] extends [T] holds, for a union it does not.
HasTags asks whether the requested tags appear consecutively, in order, anywhere in the list. StartsWith walks both tuples in lockstep from the front and returns true once the requested list runs out. Contains tries StartsWith at each position, dropping one tag per step until the list is empty. Both recursions shrink a tuple, so they terminate on the length of the tag list. HasTag is HasTags with a one element tuple, and HasExactTags skips the search and compares the whole list with Equal.
GetTags<Tag<never, 'foo'>> is ['foo']: the value can never exist, but the type still has to remember its tags, which is what the bare-TagBox branch of Attach is for.GetTags<Tag<null | 1, 'foo'>> is ['foo'] while GetTags<Tag<0, 'foo'> | 1> is []. The first is a single intersection over a union base, the second is a union of an intersection and an untagged member.HasTags<Tag<Tag<Tag<{}, 'foo'>, 'baz'>, 'bar'>, ['foo', 'bar']> is false: the tags are there, 'baz' sits between them, and Contains only accepts consecutive runs.HasExactTags<Tag<Tag<void, 'foo'>, 'bar'>, ['foo', 'bar']> is true. Unlike null, void survives an intersection with an object type, so it needs no guard at all.This challenge is originally from here.
Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges