Type | Treat 2021 – Day 4

Orta

Type | Treat Challenge 4

Welcome to the fourth Type | Treat challenge! These challenges are a series of blog posts which have 2 code challenges in, one for beginners and one for intermediate TypeScript programmers. We’re on day four, which means going over the answers from yesterday and have 2 new challenges.

Yesterday’s Solution

Beginner/Learner Challenge

I wonder if we over-indexed on the difficulty here, and we’re interested if you dropped off somewhere through this task because we had less submissions than usual for this challenge. The goal was to have you build out a template string literal type which accounted for string input which roughly matched how CSS’s stringy variables worked.

You started with:

type Length = string

Which accepts all possible strings, next we show some examples which should always fail. The key one here being that an empty string should fail: "". Next we provided some valid input for you to work with:

type Length = `${number}in`

// Works with:
req("0in")
req("12in")

Giving you a sense that a number can be used in the template slot – which allows for all sorts of possibilities.

Next we gave samples with different prefixes, so "in" and "cm" would need to be handled. To get that right, you would need to use a union:

type Unit = "cm" | "in"
type Length = `${number}${Unit}`

// Works with:
req("0in")
req("12in")
req("1.5cm")
req("20cm")

Next we threw a curve ball – "0" should also be acceptable, this is a bit of a curve ball, but also it’s a bit of a trick:

type Unit = "cm" | "in" | ""
type Length = `${number}${Unit}`

// Works with:
req("0in")
req("12in")
req("1.5cm")
req("20cm")
req("0")

The lack of a unit is just an empty string unit! Only one more thing now, and that is allowing a space inbetween the number and unit. This could be done via another type also:

type Unit = "cm" | "in" | ""
type Space = " " | ""
type Length = `${number}${Space}${Unit}`

// Works with:
req("0in")
req("12in")
req("1.5cm")
req("20cm")
req("0")
req("12 cm")
req("14 in")

That was is for the easy parts of the challenge. It’s pretty tricky, because it requires that you understand that number can be anything in the template string and to understand how a union can allow for different types of strings inside the type. That’s all in the main docs, but it could be a lot of ideas to learn at once.

This challenge also had a set of complications, cases where the version of the the Length type we expected people to build would provide interesting edge cases:

req(`${0.3e21}cm`)
req("-12 cm")
req(`${Infinity}cm`)
req(`${NaN}cm`)
Click to learn about these cases
req(`${0.3e21}cm`)

Acted as a potential clue to an alternative answer for these failing cases:

req(`${Infinity}cm`)
req(`${NaN}cm`)

Because number can be switched out with bigint in the type of Length:

- type Length = `${number}${Space}${Unit}`
+ type Length = `${bigint}${Space}${Unit}`

This meant you couldn’t pass in Infinity or NaN but also broke req("1.5cm") because you can’t have point values. This could be fixed via:

type Length = `${bigint}${Space}${Unit}` | `${bigint}.${bigint}${Space}${Unit}`

Which describes both possible cases with a “.” and without. This technique still doesn’t handle the req("-12 cm"), and actually, it introduces a whole new problem: req("-12.-12cm") is allowed!

We spotted a good answer from @danvdk which revolved around using string manipulation instead, by introducing a Digit type:

type Whitespace = '' | ' ';
type Unit = 'in' | 'cm';
type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9';
type Length = `${Digit}${number | ''}${Whitespace}${Unit}` | '0';

This solution correctly handles the case of req("-12 cm") but via that number would allow something like req("1-22 cm") – which you can pretend is to handle an input range. It wouldn’t be hard to take this solution and reasonably cover additional edge cases. Very cool solution.

Our answer

Intermediate/Advanced Challenge

The intermediate challenge was on type literals mixed with generics functions. The challenge started with this function:

function makeTitle(str: string) {
    return "<spooky>" + str.toUpperCase() + "</spooky>"
}

The goal was to keep track of string literals through this function. To do this, you need to switch the str: string to be a type argument:

function makeTitle<Str>(str: Str) {
    return "<spooky>" + str.toUpperCase() + "</spooky>"
}

You know that the type argument has to be a string, which you can tell TypeScript via <Str extends string>, then you can re-use the Str in the return position:

function makeTitle<Str extends string>(str: Str): `<spooky>${Uppercase<Str>}</spooky>` {
    return "<spooky>" + str.toUpperCase() + "</spooky>"
}

You’d think this would be it, but str.toUpperCase actually converts the str to a string! Tricky, you’d need to think creatively here and you have three options:

  1. Use an as because you know better than the compiler:

    function makeTitle<Str extends string>(str: Str): `<spooky>${Uppercase<Str>}</spooky>` {
        const shouty = str.toUpperCase() as Uppercase<Str>
        return `<spooky>${shouty}</spooky>`
    }
  2. Override toUpperCase to support template literals:

    interface String {
        toUpperCase<T extends string>(this: T) : Uppercase<T>
    }
  3. Or create a new function which supports template literals.

This would take the "party" used on line 19 and convert it to "<spooky>PARTY</spooky>". That change would remove the compiler error on addTadaEmoji.

The second part was about re-using the type parameters inside argument for the function. The challenge started with:

function setupFooter(str: string) {
    // validate string etc
    return { 
        name: str.split(",")[0],
        date: str.split(",")[1],
        address: str.split(",")[2]
    }
}

Would lose string literals passed in as str. You knew ahead of time that there were three separate parts of information you were interested in:

function setupFooter<Name extends string, Date extends string, Address extends string>(str: string) {

These could then be used inside the replacement for string:

function setupFooter<Name extends string, Date extends string, Address extends string>(str: `${Name},${Date},${Address}`) {

Which would correctly set up these variables for re-use later:

function setupFooter<Name extends string, Date extends string, Address extends string>(str: `${Name},${Date},${Address}`) {
    // validate string etc
    return { 
        name: str.split(",")[0] as Name,
        date: str.split(",")[1] as Date,
        address: str.split(",")[2] as Address
    }
}

Successfully completing this challenge would show that name, date and address were not string but the strings passed in.

Our answer.

The Challenge

Beginner/Learner Challenge

Make some candy bowls. Then make some very specific bowls.

Intermediate/Advanced Challenge

Run a set of pumpkin competitions

How To Share Your Solution

Once you feel you have completed the challenge, you will need to select the Share button in the playground. This will automatically copy a playground URL to your clipboard.

Then either:

  • Go to Twitter, and create a tweet about the challenge, add the link to your code and mention the @TypeScript Twitter account with the hashtag #TypeOrTreat.

  • Leave us a comment with your feedback on the dev.to post, or in this post.

Best Resources for Additional Help

If you need additional help you can utilize the following:

Happy Typing 🙂

2 comments

Leave a comment

  • Peter Parker

    I’m curious why just having the following type definition (ie no definition for Space)

    type Unit = "cm" | "in" 
    type Length = `${number}${Unit}`
    

    Doesn’t cause a compiler error in the playgroup for the whitespace inputs

    req("12 cm")
    req("14 in")
  • Rifat Nabi

    > It feels right that if you pass “0”, you should be able to go unit-less

    I thought the requirement was to only allow 0 w/o unit, not any number. So, I went with:

    type Length = `${number}${Unit}` | '0'

    The solution in this post also allows:

    req("12")