#36527•Medium

AssertGuid

Validate a GUID at the type level: split the string on its dashes with template literal inference, then count hex characters block by block.

A regular expression you can run before your code ever does.

AssertGuid<S> takes a string literal and hands it back untouched if it has the shape of a GUID, xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx with every x a hex character. Anything else collapses to never. It is the type-level version of a validator, and the payoff is real: a function typed declare function load<S extends string>(id: AssertGuid<S>): void refuses a malformed id in the editor instead of at runtime.

type ok = AssertGuid<'12345678-1234-1234-1234-1234567890Ab'> // '12345678-1234-1234-1234-1234567890Ab'
type bad = AssertGuid<'this-is-a-guid'> // never

Challenge Instructions: AssertGuid

Medium

Implement AssertGuid<S>.

Given a string, if the string conforms to the form of guid, return the given string; otherwise, return never.

Guid form: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, where x is a hex char ('0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'A' | 'B' | 'C' | 'D' | 'E' | 'F').

View on GitHub: https://tsch.js.org/36527

Change the following code to make the test cases pass (no type check errors).

ChallengeSolution
/* _____________ Your Code Here _____________ */

type AssertGuid<S extends string> = any

/* _____________ Test Cases _____________ */
import type { Equal, Expect } from '../helpers'

type cases = [
  Expect<Equal<AssertGuid<'12345678-1234-1234-1234-1234567890Ab'>, '12345678-1234-1234-1234-1234567890Ab'>>,
  Expect<Equal<AssertGuid<'1234567a-123b-12c4-1d34-12345e7890Ab'>, '1234567a-123b-12c4-1d34-12345e7890Ab'>>,
  Expect<Equal<AssertGuid<'1234567-12345-12-123456-1234567890ab'>, never>>,
  Expect<Equal<AssertGuid<'12345678-1234-1234-1234-1234567890gh'>, never>>,
  Expect<Equal<AssertGuid<'123

Pro Challenge

Unlock 170+ medium, hard, and extreme challenges to master advanced TypeScript.

Monthly subscription. Cancel anytime.

Detailed Explanation

The solution in full:

type HexChar =
  | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
  | 'a' | 'b' | 'c' | 'd' | 'e' | 'f'
  | 'A' | 'B' | 'C' | 'D' | 'E' | 'F'
 
type IsHexBlock<
  S extends string,
  Len extends number,
  Count extends unknown[] = [],
> = S extends `${infer Head}${infer Rest}`
  ? Head extends HexChar
    ? IsHexBlock<Rest, Len, [...Count, unknown]>
    : false
  : Count['length'] extends Len
    ? true
    : false
 
type AssertGuid<S extends string> =
  S extends `${infer A}-${infer B}-${infer C}-${infer D}-${infer E}`
    ? [
        IsHexBlock<A, 8>,
        IsHexBlock<B, 4>,
        IsHexBlock<C, 4>,
        IsHexBlock<D, 4>,
        IsHexBlock<E, 12>,
      ] extends [true, true, true, true, true]
      ? S
      : never
    : never

Two jobs, split cleanly: carve the string into five blocks, then judge each block on its own.

Carving the string into five blocks

The pattern `${infer A}-${infer B}-${infer C}-${infer D}-${infer E}` does the structural work in one step. Template literal inference is lazy, so each placeholder grabs the shortest prefix it can: A stops at the first dash, B at the second, and so on. E is the last placeholder, so it takes everything that is left.

For a well-formed input the split looks like this:

// S = '1234567a-123b-12c4-1d34-12345e7890Ab'
// A = '1234567a'
// B = '123b'
// C = '12c4'
// D = '1d34'
// E = '12345e7890Ab'

If the string does not contain four dashes, the pattern has nothing to match and the whole conditional falls straight through to never. That single line already rejects 'this-is-a-guid' (three dashes) and '12345678-1234-1234-1234567890ab' (three dashes again, because one group is missing).

The laziness also covers the opposite mistake. Given six blocks, the extra dashes end up inside E, and a dash is not a hex character, so the block check rejects it a moment later. No separate branch needed.

Counting characters with an accumulator

IsHexBlock<S, Len> answers one question: is S exactly Len hex characters long? The type system has no length for strings, so you walk the string one character at a time and keep a tally in a tuple.

S extends `${infer Head}${infer Rest}` peels off the first character. Two adjacent infer placeholders always split as one character plus the remainder, which is what makes character-by-character recursion possible at all. If Head is not in the HexChar union, the answer is false right there and the recursion stops early. Otherwise you recurse on Rest and push one element onto Count.

Count is the accumulator. Tuples are the only counters TypeScript gives you, since Count['length'] is a number literal you can compare against. Each step grows the tuple by one unknown:

// IsHexBlock<'1a3', 3>
// step 1: Head = '1', Count = []                     -> recurse on 'a3'
// step 2: Head = 'a', Count = [unknown]              -> recurse on '3'
// step 3: Head = '3', Count = [unknown, unknown]     -> recurse on ''
// step 4: '' matches nothing, Count['length'] is 3   -> 3 extends 3, so true

The empty string is the base case: it cannot match a pattern that requires at least one character, so the conditional lands in the else branch and the tally is compared with Len. A block that runs out of characters early ('1234567' against 8) reports false there, and a block that is too long overshoots and reports false for the same reason.

Comparing five answers at once

The five checks are collected in a tuple and compared with extends [true, true, true, true, true]. Nesting five conditionals would work too, but the tuple comparison stays flat and reads as one statement: every block passed, or the whole thing is never.

One detail worth naming: Head extends HexChar and the tuple comparison both sit on concrete literal types, never on a naked type parameter that could be a union, so nothing distributes here. AssertGuid<'a' | 'b'> would distribute over the union in the outer conditional, which is the behaviour you want anyway: each member is validated separately.

Edge cases the tests cover

Once you see the shape of this one, the pattern generalises. Split on the separators with template literal inference, then validate each piece with a small recursive helper and a tuple accumulator. Semantic versions, IPv4 addresses and ISO dates all yield to the same two moves.

This challenge is originally from here.

Share this challenge

Related Challenges

Learn the Concepts

Become a TypeScript Pro

Track your progress through 100+ hands-on challenges. Free, sign in with GitHub.

Or start solving right away: explore all TypeScript challenges