Skip to content

Commit

Permalink
add branded type
Browse files Browse the repository at this point in the history
  • Loading branch information
andrej-dyck committed May 11, 2024
1 parent 611095e commit f939e77
Show file tree
Hide file tree
Showing 6 changed files with 91 additions and 0 deletions.
1 change: 1 addition & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default tseslint.config(

/* typescript */
'@typescript-eslint/consistent-type-definitions': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-confusing-void-expression': ['error', { ignoreArrowShorthand: true }],
'@typescript-eslint/prefer-readonly': ['warn'],
'@typescript-eslint/space-before-blocks': ['error'],
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"pnpm": ">=9.1.0"
},
"devDependencies": {
"@type-challenges/utils": "0.1.1",
"@types/node": "20.12.11",
"eslint": "9.2.0",
"typescript": "5.4.5",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions types/Branded.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Equal, Expect, NotEqual } from '@type-challenges/utils'
import { expect, test } from 'vitest'
import { raise } from '../raise/index.js'
import { Branded } from './Branded.ts'

/** branded numbers */
type Age = Branded<number, 'Age'>
type Year = Branded<number, 'Year'>

const validAge = (age: number): Age | undefined =>
age >= 0 && age <= 125 ? age as Age : undefined

const birthYear = (age: Age, now: Date): Year =>
now.getFullYear() - age as Year // it's incorrect, but sufficient for demo purpose

test('birthYear is compile-time safe', () => {
const fiveYears = validAge(5) ?? raise('invalid age')
expect(
birthYear(fiveYears, new Date(2024, 4, 11))
).toBe(2019)
})

test('birthYear is compile-time safe', () => {
expect(
// @ts-expect-error argument must be Age
birthYear(5, new Date(2024, 4, 11))
).toBe(2019)
})

const someAge = validAge(5) ?? raise('invalid age')
const someBirthYear = birthYear(someAge, new Date(2024, 4, 11))

// @ts-expect-error unused type as tests are compiler-based
type AgeTests = [
Expect<Equal<typeof someAge, Age>>,
Expect<NotEqual<typeof someAge, number>>,
Expect<Equal<Parameters<typeof validAge>, [number]>>,
Expect<Equal<Parameters<typeof birthYear>, [Age, Date]>>,
Expect<Equal<typeof someBirthYear, Year>>,
Expect<NotEqual<typeof someBirthYear, number>>,
]

/** branded string */
type Email = Branded<string, 'Email'>

const emailRegex = /^(([^<>()[]\\.,;:\s@"]+(\.[^<>()[]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const validEmail = (email: string): Email | undefined =>
emailRegex.test(email) ? email as Email : undefined

const someEmail = validEmail('[email protected]') ?? raise('invalid email')

// @ts-expect-error unused type as tests are compiler-based
type EmailTests = [
Expect<Equal<typeof someEmail, Email>>,
Expect<NotEqual<typeof someEmail, string>>,
Expect<Equal<Parameters<typeof validEmail>, [string]>>,
]
23 changes: 23 additions & 0 deletions types/Branded.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
declare const __brand: unique symbol
type Brand<B> = { [__brand]: B }

/**
* A branded type for compile-time safe usage of (primitive) values.
*
* Example usage:
* type Email = Branded<string, 'email'>
*
* const validEmail = (email: string): Email | undefined =>
* emailRegex.test(email) ? email as Email : undefined
*
* const subscribe = (email: Email) => {
* ...
* }
*
* ...
*
* ❌ subscribe('[email protected]') // ts-error
* ✅ const email = validEmail('[email protected]') ?? raise('invalid email')
* subscribe(email) // no ts-error
*/
export type Branded<T, B> = T & Brand<B>
1 change: 1 addition & 0 deletions types/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './Branded.ts'
export * from './DeepPartial.ts'

0 comments on commit f939e77

Please sign in to comment.