-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* 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
Showing
10 changed files
with
473 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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! |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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] || '' | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'>>() | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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": [] | ||
} |
Oops, something went wrong.