Skip to content

Commit

Permalink
Ts brand (#1163)
Browse files Browse the repository at this point in the history
* ts-brand package

* ctor type

* testing

* testing

* get brand fn

* more tests

* more testing

* resolve brand

* move ts-brand to dev

* Update package.json

* readme

* add class branding

* no brand readme

* unbrand fix never

* rename brandField to brandKey

* fix tests

* note about compile time types

* version

* fix resolve

* lint

* lint

* remove dep
  • Loading branch information
goastler authored Apr 23, 2024
1 parent e4645ce commit 8b781b6
Show file tree
Hide file tree
Showing 10 changed files with 473 additions and 0 deletions.
17 changes: 17 additions & 0 deletions dev/ts-brand/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/node_modules/
/src/
/tests/
/artifacts/
tsconfig.json
tsconfig.*.json
tsconfig.tsbuildinfo
env.production
env.development
env.test
.env.*
webpack.*
*.ipynb
captchas_*.json
data.json
stl10/*.json
stl10
132 changes: 132 additions & 0 deletions dev/ts-brand/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Brand your TypeScript types!

See our [tutorial](https://prosopo.io/articles/typescript-branding/) for more info on what branding is and why you'd want to use it.

## Nominal types example

```ts
type A = {
x: number
y: boolean
z: string
}

type B = {
x: number
y: boolean
z: string
}
```
Type `A` and `B` are equal in the eyes of TypeScript.
```ts
const fn = (a: A) => {
console.log('do something with A')
}

const obj: B = {
x: 1,
y: true,
z: 'hello',
}

fn(obj) // absolutely fine, even though fn accepts types of A and obj is of type B!
```

Let's brand `A`

```ts
type ABranded = Brand<A, 'A'> // {
// x: number;
// y: boolean;
// z: string;
// } & {
// [brandKey]: "A";
// }
```
```ts
const fn = (a: ABranded) => {
console.log('do something with A')
}

const obj: B = {
x: 1,
y: true,
z: 'hello',
}

fn(obj) // Now this doesn't work, cannot accept any type other than ABranded!
```

Now the function only accepts a set type.

## Mapped type example

Using `ABranded` from before, we can do conditional typing.

```ts
type IsA<T> = T extends ABranded ? true : false

type x = IsA<ABranded> // true
type y = IsA<B> // false
```
Obviously this is a simple example, but branding enables conditional typing. This would be impossible using regular types in TypeScript, because type `B` is seen as equal to type `A`. [Read our blog post for a more detailed explanation](https://prosopo.io/articles/typescript-mapped-type-magic/).
## Classes & Instances
You can brand instances of a class or the class itself (which will produce branded instances).
```ts
class Dog {
constructor(public name: string) {}
}

const DogBranded = brandClass(Dog, 'Dog') // adds the 'Dog' brand, making a new type

const dog = new DogBranded('Spot') // ok, of type DogBranded
```

Conditional typing can now be done using classes.

Or to brand an instance:

```ts
const dogBranded = brand(new Dog(), 'Dog') of type Dog & { [brandKey]: 'Dog' }
```

## Unbranding

Simply do the inverse to get back to the original type.

```ts
const DogUnbranded = unbrandClass(DogBranded) // same as the Dog class

const dog = new DogUnbranded('Spot') // ok, of type Dog
```

Or to unbrand an instance:

```ts
const dogUnbranded = unbrand(dog) // of type Dog
```

## Get brand

Given a unknown branded value

```ts
const b = getBrand(dog) // b is of type 'Dog'
```

No brand set:

```ts
const b = getBrand(someValue) // b is of type '' - i.e. no brand
```

## Important note

Branding is type only, so only available at compile time! This means using things like `getBrand()` will give you the brand as a type, not as a runtime variable!
50 changes: 50 additions & 0 deletions dev/ts-brand/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@prosopo/ts-brand",
"version": "0.3.38",
"description": "Brand your TypeScript types",
"main": "./dist/index.js",
"type": "module",
"engines": {
"node": ">=18",
"npm": ">=9"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/cjs/index.cjs"
}
},
"types": "./dist/index.d.ts",
"scripts": {
"clean": "tsc --build --clean",
"build": "tsc --build --verbose",
"build:cjs": "npx vite --config vite.cjs.config.ts build",
"test": "npx vitest run --config ./vite.test.config.ts",
"eslint": "npx eslint . --no-error-on-unmatched-pattern --ignore-path ../../.eslintignore",
"eslint:fix": "npm run eslint -- --fix",
"prettier": "npx prettier . --check --no-error-on-unmatched-pattern --ignore-path ../../.eslintignore",
"prettier:fix": "npm run prettier -- --write",
"lint": "npm run eslint && npm run prettier",
"lint:fix": "npm run eslint:fix && npm run prettier:fix"
},
"author": "Prosopo Limited",
"license": "Apache-2.0",
"dependencies": {},
"devDependencies": {
"tslib": "2.6.2",
"typescript": "5.1.6",
"vitest": "^1.3.1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/prosopo/captcha.git"
},
"bugs": {
"url": "https://github.com/prosopo/captcha/issues"
},
"homepage": "https://github.com/prosopo/captcha/blob/main/packages/ts-brand/README.md",
"publishConfig": {
"registry": "https://registry.npmjs.org"
},
"sideEffects": false
}
47 changes: 47 additions & 0 deletions dev/ts-brand/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright 2021-2024 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
export type Ctor<T> = new (...args: any[]) => T
// resolve intersection types
// eslint-disable-next-line
export type Resolve<T> = T extends Function ? T : { [K in keyof T]: T[K] }

export const brandKey = Symbol('brand')

export type Brand<T, U> = Resolve<
T & {
[brandKey]: U
}
>

export type Unbrand<T> = T extends Brand<infer U, any> ? U : T

export const brandClass = <T, const U>(ctor: Ctor<T>, name: U) => {
return ctor as Ctor<Brand<T, typeof name>>
}

export const unbrandClass = <T>(ctor: Ctor<T>) => {
return ctor as Ctor<Unbrand<T>>
}

export const brand = <T, const U>(value: T, name: U) => {
return value as Brand<T, typeof name>
}

export const unbrand = <T>(value: T) => {
return value as Unbrand<T>
}

export const getBrand = <T>(value: T) => {
return (value as any)[brandKey] || ''
}
139 changes: 139 additions & 0 deletions dev/ts-brand/src/tests/brand.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Copyright 2021-2024 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Brand, brand, brandClass, brandKey, getBrand, unbrand } from '../index.js'
import { describe, expect, expectTypeOf, test } from 'vitest'

export type IfEquals<X, Y, A = X, B = never> = (<T>() => T extends X ? 1 : 2) extends <T>() => T extends Y ? 1 : 2
? A
: B

describe('brand', () => {
test('type branding', () => {
type A = Brand<
{
c: true
},
'A'
>

type B = Brand<
{
c: true
},
'B'
>

type C<T> = T extends A ? true : false

type a = C<A>
type b = C<B>

// expect the types to be true/false appropriately
const c: b = false
const d: a = true

// expect the types to be unequal
type e = IfEquals<A, B, true, false>
const f: e = false // expect false
})

test('branding classes', () => {
class A {
constructor(public x: number) {}
}

const ABranded = brandClass(A, 'A')

const aBrandedInst = new ABranded(1)

expectTypeOf(aBrandedInst).toMatchTypeOf<{
x: number
}>()

expectTypeOf(aBrandedInst).toMatchTypeOf<Brand<A, 'A'>>()
})

test('get brand', () => {
class A {
constructor(public x: number) {}
}

const ABranded = brandClass(A, 'A')

const aBrandedInst = new ABranded(1)

const brand2 = getBrand(aBrandedInst)

expectTypeOf(brand2).toMatchTypeOf<'A'>()
})

test('get brand - no brand', () => {
class A {
constructor(public x: number) {}
}

const a = new A(1)

const brand2 = getBrand(a)

expect(brand2).toBe('')
expectTypeOf(brand2).toMatchTypeOf<''>()
})

test('unbrand', () => {
class A {
constructor(public x: number) {}
}

const ABranded = brand(A, 'A')

const aBrandedInst = new ABranded(1)

const a = unbrand(aBrandedInst)

expect(getBrand(a)).toBe('')
expectTypeOf(a).toMatchTypeOf<A>()
})

test('branded classes are not equal', () => {
class A {
constructor(public x: number) {}
}

const ABranded = brandClass(A, 'A')
const BBranded = brandClass(A, 'B')

const a = new ABranded(1)
const b = new BBranded(1)

expectTypeOf(a[brandKey]).toMatchTypeOf<'A'>()
expectTypeOf(b[brandKey]).toMatchTypeOf<'B'>()

expectTypeOf(a).not.toEqualTypeOf(b)
type c = IfEquals<typeof a, typeof b, true, false>
const d: c = false // should not be equal
})

test('instance branding', () => {
class A {
constructor(public x: number) {}
}

const a = new A(1)
const aBranded = brand(a, 'A')

expectTypeOf(getBrand(aBranded)).toMatchTypeOf<'A'>()
expectTypeOf(aBranded).toMatchTypeOf<Brand<A, 'A'>>()
})
})
9 changes: 9 additions & 0 deletions dev/ts-brand/tsconfig.cjs.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.cjs.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist/cjs"
},
"include": ["./src/**/*.ts", "./src/**/*.json", "./src/**/*.d.ts", "./src/**/*.tsx"],
"references": []
}
Loading

0 comments on commit 8b781b6

Please sign in to comment.