diff --git a/dev/ts-brand/.npmignore b/dev/ts-brand/.npmignore new file mode 100644 index 0000000000..5248cae047 --- /dev/null +++ b/dev/ts-brand/.npmignore @@ -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 diff --git a/dev/ts-brand/README.md b/dev/ts-brand/README.md new file mode 100644 index 0000000000..ce9a7e877e --- /dev/null +++ b/dev/ts-brand/README.md @@ -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 // { +// 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 extends ABranded ? true : false + +type x = IsA // true +type y = IsA // 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! diff --git a/dev/ts-brand/package.json b/dev/ts-brand/package.json new file mode 100644 index 0000000000..9e3df3c75e --- /dev/null +++ b/dev/ts-brand/package.json @@ -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 +} diff --git a/dev/ts-brand/src/index.ts b/dev/ts-brand/src/index.ts new file mode 100644 index 0000000000..24b44d85ed --- /dev/null +++ b/dev/ts-brand/src/index.ts @@ -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 = new (...args: any[]) => T +// resolve intersection types +// eslint-disable-next-line +export type Resolve = T extends Function ? T : { [K in keyof T]: T[K] } + +export const brandKey = Symbol('brand') + +export type Brand = Resolve< + T & { + [brandKey]: U + } +> + +export type Unbrand = T extends Brand ? U : T + +export const brandClass = (ctor: Ctor, name: U) => { + return ctor as Ctor> +} + +export const unbrandClass = (ctor: Ctor) => { + return ctor as Ctor> +} + +export const brand = (value: T, name: U) => { + return value as Brand +} + +export const unbrand = (value: T) => { + return value as Unbrand +} + +export const getBrand = (value: T) => { + return (value as any)[brandKey] || '' +} diff --git a/dev/ts-brand/src/tests/brand.test.ts b/dev/ts-brand/src/tests/brand.test.ts new file mode 100644 index 0000000000..cc498eb5d2 --- /dev/null +++ b/dev/ts-brand/src/tests/brand.test.ts @@ -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 = (() => T extends X ? 1 : 2) extends () => 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 extends A ? true : false + + type a = C + type b = C + + // 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 + 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>() + }) + + 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() + }) + + 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 + 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>() + }) +}) diff --git a/dev/ts-brand/tsconfig.cjs.json b/dev/ts-brand/tsconfig.cjs.json new file mode 100644 index 0000000000..f3e9cca959 --- /dev/null +++ b/dev/ts-brand/tsconfig.cjs.json @@ -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": [] +} diff --git a/dev/ts-brand/tsconfig.json b/dev/ts-brand/tsconfig.json new file mode 100644 index 0000000000..e51bd3ae07 --- /dev/null +++ b/dev/ts-brand/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.esm.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src", "src/**/*.json"], + "references": [] +} diff --git a/dev/ts-brand/typedoc.config.js b/dev/ts-brand/typedoc.config.js new file mode 100644 index 0000000000..61ca9d97a0 --- /dev/null +++ b/dev/ts-brand/typedoc.config.js @@ -0,0 +1,19 @@ +// 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 default { + entryPoints: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.js', 'src/**/*.jsx', 'src/**/*.json'], + includes: 'src', + extends: '../../typedoc.base.config.js', + readme: 'README.md', +} diff --git a/dev/ts-brand/vite.cjs.config.ts b/dev/ts-brand/vite.cjs.config.ts new file mode 100644 index 0000000000..2a243e2dbf --- /dev/null +++ b/dev/ts-brand/vite.cjs.config.ts @@ -0,0 +1,19 @@ +// 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 { ViteCommonJSConfig } from '@prosopo/config' +import path from 'path' + +export default function () { + return ViteCommonJSConfig('common', path.resolve('./tsconfig.cjs.json')) +} diff --git a/dev/ts-brand/vite.test.config.ts b/dev/ts-brand/vite.test.config.ts new file mode 100644 index 0000000000..29fa71d92d --- /dev/null +++ b/dev/ts-brand/vite.test.config.ts @@ -0,0 +1,32 @@ +// 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 { ViteTestConfig } from '@prosopo/config' +import dotenv from 'dotenv' +import fs from 'fs' +import path from 'path' +process.env.NODE_ENV = 'test' +// if .env.test exists at this level, use it, otherwise use the one at the root +const envFile = `.env.${process.env.NODE_ENV || 'development'}` +let envPath = envFile +if (fs.existsSync(envFile)) { + envPath = path.resolve(envFile) +} else if (fs.existsSync(`../../${envFile}`)) { + envPath = path.resolve(`../../${envFile}`) +} else { + throw new Error(`No ${envFile} file found`) +} + +dotenv.config({ path: envPath }) + +export default ViteTestConfig