Find the discriminant key of a tagged union: the property every member shares with a distinct string literal value. Catching duplicate tags is the hard part.
Every switch (action.type) you write leans on one key. This challenge goes looking for it.
A tagged union, also called a discriminated union, is a union whose members can be told apart by a single property. ExtractTagFromTaggedUnion<T> reports the name of that property, with one restriction from the spec: the tag has to be a string. When no property qualifies, or when two members carry the same tag value, the answer is never.
type MyTaggedUnion =
| { type: 'position'; position: [x: number, y: number] }
| { type: 'direction'; direction: [x: number, y: number] }
type Tag = ExtractTagFromTaggedUnion<MyTaggedUnion> // 'type' expected.A tagged union, or discriminated union, is a union type in which every member type is distinguishable by a property called the tag.
Write an ExtractTagFromTaggedUnion type that takes a tagged union and
yields the name of the tag property.
The tag must be a string.
type MyTaggedUnion =
| { type: 'position'; position: [x: number, y: number] }
| { type: 'direction'; direction: [x: number, y: number] }
type Tag = ExtractTagFromTaggedUnion<MyTaggedUnion> // 'type' expected.View on GitHub: https://tsch.js.org/33616
Change the following code to make the test cases pass (no type check errors).
/* _____________ Your Code Here _____________ */
type ExtractTagFromTaggedUnion<T> = 'type'
/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'
type Foo = { type: 'foo'; foo: 'hello' } | { type: 'bar'; bar: 'world' }
type Bar = { foo: 'foo' } | { bar: 'bar' }
type Baz = { type: 'number'; value: number } | { type: 'string'; value: string }
type Qux =
| { type: 'position'; position: [x: number, y: number] }
| { type: 'direction'; direction: [x: number, y: number] }
type Quux =
| { type: 'position'; value: [x: number, y: number] }
| { type: 'dirGet access to all 200+ challenges, including every medium, hard, and extreme one.
One-time payment, lifetime access. 30-day money-back guarantee.
The solution in full:
type IsStringLiteral<V> = [V] extends [string]
? string extends V
? false
: true
: false
type IsUnion<X, Copy = X> = X extends unknown
? [Copy] extends [X]
? false
: true
: never
type IsTagKey<T, K extends PropertyKey, Member = T> =
Member extends Record<K, infer V>
? IsStringLiteral<V> extends true
? [IsUnion<Extract<T, Record<K, V>>>] extends [false]
? true
: false
: false
: false
type TagKeys<T, K> = K extends PropertyKey
? [IsTagKey<T, K>] extends [true]
? K
: never
: never
type ExtractTagFromTaggedUnion<T> = TagKeys<T, keyof T>A key is a tag when three things hold: every member has it, every member types it as a string literal, and no two members give it the same value. That last condition is where most attempts fall over.
keyof on a union does not collect every key it can see. It returns the keys shared by all members, because those are the only ones you can safely read off a value of that type.
type K1 = keyof ({ type: 'foo'; foo: 'hello' } | { type: 'bar'; bar: 'world' })
// 'type' ('foo' and 'bar' each live in a single member)
type K2 = keyof ({ foo: 'foo' } | { bar: 'bar' })
// neverkeyof T is therefore the candidate list, and a union with nothing in common is answered before the real work starts.
IsTagKey<T, K, Member = T> receives the union twice. Member is the one being tested against a conditional type, so it distributes and the body runs once per member with Member narrowed to that member alone. T takes no part in the distribution, so it still names the whole union inside the body. Both are needed, because "is this value unique" can only be settled by holding one member up against all of them.
The property is read with infer instead of Member[K], which keeps TypeScript from objecting that a bare K may not index Member:
type V = { type: 'foo'; foo: 'hello' } extends Record<'type', infer V> ? V : never
// 'foo'The pattern doubles as a presence check: a member without the key cannot match it, and the false branch drops the candidate.
A property typed string tells members apart in theory and never in practice, so the wide type has to be separated from its literals. Running the assignability test backwards does it:
type A = IsStringLiteral<'foo'> // true, because string extends 'foo' is false
type B = IsStringLiteral<string> // false, because string extends string holds
type C = IsStringLiteral<1> // false, not a string at allOnly the wide string swallows string, so string extends V is the test for "V is too wide". The brackets in [V] extends [string] stop the check from distributing, so the property is judged as a whole.
Two members may not claim the same tag value. Collect everyone who claims the current member's value, then count:
type Grault = { type: 'A'; value: number } | { type: 'B'; value: number } | { type: 'B'; value: string }
type Claimants = Extract<Grault, Record<'type', 'B'>>
// { type: 'B'; value: number } | { type: 'B'; value: string }More than one claimant means the value is duplicated, and "more than one" is what IsUnion measures: it distributes over X and asks whether the whole union fits back into the single member it is currently looking at. A lone member absorbs itself and yields false, while neither half of Claimants absorbs the other, so both branches yield true. That also explains why the final check reads extends [false] rather than extends [true]. One test pairs a member with a strictly wider copy of itself:
type Thud = { type: 'same'; same: number } | { type: 'same'; same: number; really: false }
type Verdict = IsUnion<Thud> // booleanThe wider member is assignable to the narrower one, so the branch looking at the narrow member answers false while the branch looking at the wide member answers true. Their union is boolean, neither a clean yes nor a clean no. Demanding [IsUnion<...>] extends [false] asks for a provable "not a union" and sends this ambiguous pair to never, which is the expected result.
IsTagKey produces one boolean per member, so its result is a union of booleans. Wrapping it in a tuple turns "every member agreed" into a single comparison, since true | false is not assignable to true. TagKeys then distributes over K, judges each candidate key on its own, and rebuilds the union from the survivors. Rejected keys contribute never and vanish; a union with two working tags reports both.
{ foo: 'foo' } | { bar: 'bar' } → never, since keyof of that union is already never.{ type: 1 } | { type: '2' } → never: the numeric literal fails the string rule, and one bad member sinks the key.{ seq: '1'; type: 'one' } | { seq: '2'; type: 'two' } → 'seq' | 'type', both reported.type: 'B' → never, caught by Extract plus IsUnion.{ type: 'same'; same: number } written twice → 'type': the members are structurally identical, so there is only one of them.{} → never: no keys, no candidates.This challenge is originally from here.
Track your progress through 200+ hands-on challenges. Free, sign in with GitHub.
Or start solving right away: explore all TypeScript challenges