Typing basic types with Typescript

So here’s an issue. We have string
s and we have ISOStrings
and ids
.
- We don’t want to be able to assign any
string
to a field that hasISOString
butISOString
should be assignable tostring
- We don’t want an
id
of one data type to be assignable to an id of another data type.
1: Template literal types
Template literal types have been a part of Typescript since 4.1. Now we can specify a string
that fulfills a template.
type Hello<Greeted extends string> = `Hello, ${Greeted}!`
type HelloWorld = Hello<'World'>
const hello1: HelloWorld = 'Hello, World!'
const hello2: HelloWorld = 'Hello, Oli!' // error
This way we can define an ISOString by using ${number}
. Be careful though since the resulting type will be a Union
type of too many fields if you do it “properly”. Here’s how we would create a type for ISOString
s:
type PaddedNumber = `${'0' | ''}${number}`
type ISODate = `${number}-${PaddedNumber}-${PaddedNumber}`
type ISOTime = `${PaddedNumber}:${PaddedNumber}:${PaddedNumber}`
export type ISOString = `${ISODate}T${ISOTime}Z`
2: Branding
Conventional branding
So far people have used some sort of “Branding” approach to having an id
not assignable to another id
. For example:
interface UserBrand { type: 'User' }
type UserId = string & UserBrandinterface ArticleBrand { type: 'Article' }
type ArticleId = string & ArticleBrand
now when we…
const userId: UserId = user.id // ok
const articleId: ArticleId = userId // error
…with a great benefit to our type-checking. This approach does cause issues when we do this though.
const userId: UserId = 'user-id' // error
const userId2: UserId = 'user-id' as UserId // casting is ok
Alternative 1: Branding that allows assigning basic types
So here’s a solution from Drew Colthorp’s article on Opaque Types (in his article “Flavoring”) by using this interface we create a union types with string
instead that are not inter-assignable
interface OpaqueType<OpaqueT> {
_type?: OpaqueT;
}
export type Opaque<T, OpaqueT> = T & OpaqueType<OpaqueT>;
Now if we change our id types to…
type UserId = Opaque<string, "User”>
type ArticleId = Opaque<number, "Article">
we now get this effect:
const userId: UserId = 'user-id' // ok
const articleId: ArticleId = 'article-id // ok
const userId2: UserId = articleId // error
Using “unique symbol”
To conserve a single source of truth with branding and to make sure that two brands can never be the same using unique symbol
can handle those cases. As an example we can create two ISOString brands like so:
type ISOString = string & {readonly ISOString: unique symbol}
type ISOString2 = string & {readonly ISOString: unique symbol}
let isoString1: ISOString = 'isostring' as ISOString // ok
let isoString2: ISOString2 = 'isostring2' as ISOString2 // ok
isoString1 = isoString2 // error
You can likewise instead of making an Opaque type with string
literals you can brand it with a unique symbol instead.
type ISOString = Opaque<string, { readonly T: unique symbol }>
type ISOString2 = Opaque<string, { readonly T: unique symbol }>
const isostring: ISOString = 'isostring'
let alsoIsostring: ISOString2 = 'isostring'
alsoIsostring = isostring // error
The difference is subtle. Let’s say two people create the same brand with the same signature then with unique symbol
they’ll will not be interchangeable. The bigger the codebase the more you’ll have to know that you’re importing the correct CommentId
or type for example.
When should you use which?
That, of course, depends on your needs.
- If you want a built-in string validation then use a Template Literal
- If you want to make sure that people don’t mix ids or strings of different formats then use some sort of branding.
- If you need both, then use both.
And that’s all for now, happy coding!
Appendix: Book reccomendations
These books have allowed me to leapfrog a couple of years of experience, and it could do the same for you.
* Clean Code by Robert C. Martin
* Clean Architecture by Robert C. Martin