diff --git a/eslint.config.mjs b/eslint.config.mjs index 9c0e3e4..f350b8e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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'], diff --git a/package.json b/package.json index e2de296..d5de04c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c5b769..b482386 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: devDependencies: + '@type-challenges/utils': + specifier: 0.1.1 + version: 0.1.1 '@types/node': specifier: 20.12.11 version: 20.12.11 @@ -285,6 +288,9 @@ packages: '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + '@type-challenges/utils@0.1.1': + resolution: {integrity: sha512-A7ljYfBM+FLw+NDyuYvGBJiCEV9c0lPWEAdzfOAkb3JFqfLl0Iv/WhWMMARHiRKlmmiD1g8gz/507yVvHdQUYA==} + '@types/estree@1.0.5': resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -1182,6 +1188,8 @@ snapshots: '@sinclair/typebox@0.27.8': {} + '@type-challenges/utils@0.1.1': {} + '@types/estree@1.0.5': {} '@types/json-schema@7.0.15': {} diff --git a/types/Branded.test.ts b/types/Branded.test.ts new file mode 100644 index 0000000..29e6fc0 --- /dev/null +++ b/types/Branded.test.ts @@ -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 +type Year = Branded + +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\'s argument is still just a number in JS', () => { + 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>, + Expect>, + Expect, [number]>>, + Expect, [Age, Date]>>, + Expect>, + Expect>, +] + +/** branded string */ +type Email = Branded + +const emailRegex = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/ // a simplified regex +const validEmail = (email: string): Email | undefined => + emailRegex.test(email) ? email as Email : undefined + +const someEmail = validEmail('abc@mail.com') ?? raise('invalid email') + +// @ts-expect-error unused type as tests are compiler-based +type EmailTests = [ + Expect>, + Expect>, + Expect, [string]>>, +] diff --git a/types/Branded.ts b/types/Branded.ts new file mode 100644 index 0000000..8ec471c --- /dev/null +++ b/types/Branded.ts @@ -0,0 +1,23 @@ +/** + * A branded type for compile-time safe usage of (primitive) values. + * + * Example usage: + * type Email = Branded + * + * const validEmail = (email: string): Email | undefined => + * emailRegex.test(email) ? email as Email : undefined + * + * const subscribe = (email: Email) => { + * ... + * } + * + * ... + * + * ❌ subscribe('abc@mail.com') // ts-error + * ✅ const email = validEmail('abc@mail.com') ?? raise('invalid email') + * subscribe(email) // no ts-error + */ +export type Branded = T & Brand + +type Brand = { [__brand]: B } +declare const __brand: unique symbol diff --git a/types/index.ts b/types/index.ts index cd80594..efe43e2 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1 +1,2 @@ +export * from './Branded.ts' export * from './DeepPartial.ts'