Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(xor): support xoring more than two types (up to 200!) #27

Merged
merged 1 commit into from
Sep 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# editorconfig.org
root = true

[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false
1 change: 1 addition & 0 deletions .npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ tsconfig.json
.gitignore
.npmrc
.travis.yml
.editorconfig
59 changes: 31 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

# ts-xor

The npm package `ts-xor` introduces the new mapped type `XOR` that helps you compose your own custom TypeScript types containing mutually exclusive keys.
The tiny npm package `ts-xor` introduces the new mapped type `XOR` that helps you compose your own custom TypeScript types containing mutually exclusive keys for zero runtime overhead.

## Description

Expand Down Expand Up @@ -60,9 +60,11 @@ then the derived type is shown quite differently in VS Code:

### How it works

Notice in the example above, that when using XOR each "variant" of the resulting type contains all keys of one source type plus all keys of the other. At the same time those keys of the second type are defined as _optional_ while additionally they are also typed as _undefined_.
Notice in the example above, that when using `XOR`, each union branch of the resulting type contains all keys of one source type plus all keys of the other. At the same time, in each variant, those keys of the other type are defined as _optional_ while additionally they are also typed as _undefined_.

This trick will not only forbid defining keys of both source types at the same time (since the type of each key is explicitly `undefined`), but also _allow_ us to not need to define all keys all of the time since each set of keys is optional on each variant.
This trick will not only forbid having keys of both source types defined at the same time (since the type of each key is explicitly `undefined`), but also _allow_ us to not need to define all keys all of the time since each set of keys is optional on each variant.

>_Fun fact: The actual TypeScript code for `XOR` [is generated programmatically](https://github.com/maninak/ts-xor/pull/27) using the TypeScript Compiler API._ 🦾

## Installation

Expand All @@ -74,8 +76,6 @@ npm install -D ts-xor

## Usage

### A simple scenario

```typescript
import type { XOR } from 'ts-xor'

Expand All @@ -90,7 +90,32 @@ test = { a: '', b: '' } // error
test = {} // error
```

### A realistic scenario
### XORing more than two types

If you want to create a type as the product of the logical XOR operation between multiple types (more than two and even up to 200), then just pass them as additional comma-separated generic params.

```typescript
let test: XOR<A, B, C, D, E, F>
```

`ts-xor` can easily handle up to 200 generic params. 💯

### Pattern 1: Typing a fetcher returning data XOR error

Using `XOR` we can type a function that returns either the data requested from an API or a response object like so:

```ts
type FetchResult<P extends object> = XOR<
{ data: P },
{ error: FetchError<P> },
>
```

Now TypeScript has all the necessary information to infer if the `FetchResult` contains a `data` or `error` key _at compile time_ which results in very clean, yet strictly typed, handling code.

![data or error intellisense demo](./assets/dataOrError-intellisense.gif)

### Pattern 2: Typing an API's response shape

Let's assume that we have the following spec for a weather forecast API's response:

Expand All @@ -100,8 +125,6 @@ Let's assume that we have the following spec for a weather forecast API's respon
4. The rain, snow members _always_ contain either a member `1h` or a member `3h` with a number value, but _never_ both keys at the same time.

```typescript
import type { XOR } from 'ts-xor'

type ForecastAccuracy = XOR<{ '1h': number }, { '3h': number }>

interface WeatherForecastBase {
Expand Down Expand Up @@ -134,26 +157,6 @@ const test: WeatherForecast = {
}
```

### XORing more than two types

If you want to create a type as the product of the logical XOR operation between multiple types (more than two), then nest the generic params.

```typescript
import type { XOR } from 'ts-xor'

interface A { a: string }
interface B { b: string }
interface C { c: string }

let test: XOR<A, XOR<B, C>>

test = { a: '' } // OK
test = { b: '' } // OK
test = { c: '' } // OK
test = { a: '', c: '' } // error
test = {} // error
```

## Tests and coverage

The library `ts-xor` is fully covered with smoke, acceptance and mutation tests against the typescript compiler itself. The tests can be found inside the [`test`](https://github.com/maninak/ts-xor/tree/master/test) folder.
Expand Down
Binary file added assets/dataOrError-intellisense.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@
},
"sideEffects": false,
"scripts": {
"codegen": "node ./src/xorFactory.js > ./src/types/xor.ts",
"prebuild": "npm run codegen",
"build": "tsup src/index.ts --format esm,cjs --dts --sourcemap --clean",
"pretest": "npm run codegen",
"test": "npm run test:smoke && npm run test:unit && npm run test:package",
"test:smoke": "tsc -p . --noEmit",
"test:unit": "sh scripts/run-tests.sh",
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export * from './types/xor.js'
export type * from './types/xor.js'
4 changes: 4 additions & 0 deletions src/types/evalIfNotUnknown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* Skip evaluating `U` if `T` is `unknown`.
*/
export type EvalIfNotUnknown<T, U> = unknown extends T ? never : U;
30 changes: 17 additions & 13 deletions src/types/xor.ts

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions src/xorFactory.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const ts = require('typescript')

const xorParamCount = 200
const countOfUniqueLetters = 20
/**
* Contains ['A', 'B', ..., <countOfUniqueLetters'th_letter_used_in_Array_constructor>]
*/
const uniqueLetters = [...Array(countOfUniqueLetters).keys()]
.map(i => String.fromCharCode(i + 65))
const allParamNames = getUniqueSymbolPermutationsGivenPool(uniqueLetters, xorParamCount)
const [,, ...paramNamesExcludingANorB] = allParamNames

function createXor() {
const modifiers = [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)]
const name = ts.factory.createIdentifier('XOR')
const typeParams = createXorParams()
const type = createXorType()

return ts.factory.createTypeAliasDeclaration(modifiers, name, typeParams, type)
}

function createXorParams() {
const xorParams = [
ts.factory.createTypeParameterDeclaration(undefined, ts.factory.createIdentifier('A')),
ts.factory.createTypeParameterDeclaration(undefined, ts.factory.createIdentifier('B')),
...paramNamesExcludingANorB.map((letter) => ts.factory.createTypeParameterDeclaration(
undefined,
ts.factory.createIdentifier(letter),
undefined,
ts.factory.createTypeReferenceNode('unknown')
))
]

return xorParams
}

function createXorType() {
const unionOfWithouts = ts.factory.createUnionTypeNode([
createWithoutLettersIntersectingLetter(
allParamNames.filter((letterToExclude) => letterToExclude !== 'A'),
'A',
),
createWithoutLettersIntersectingLetter(
allParamNames.filter((letterToExclude) => letterToExclude !== 'B'),
'B',
),
...paramNamesExcludingANorB.map(
(letter) => ts.factory.createTypeReferenceNode(
'EvalIfNotUnknown',
[
ts.factory.createTypeReferenceNode(letter),
createWithoutLettersIntersectingLetter(
allParamNames.filter((letterToExclude) => letterToExclude !== letter),
letter,
),
]
)
)
])

const type = ts.factory.createTypeReferenceNode('Prettify', [unionOfWithouts])

return type
}

/**
* @param {string[]} lettersExcludingLetter
* @param {string} excludedLetter
*/
function createWithoutLettersIntersectingLetter(lettersExcludingLetter, excludedLetter) {
const withoutLettersIntersectingLetter = ts.factory.createIntersectionTypeNode([
createWithout(lettersExcludingLetter, excludedLetter),
ts.factory.createTypeReferenceNode(excludedLetter)
])

return withoutLettersIntersectingLetter
}

/**
* @param {string[]} lettersExcludingLetter
* @param {string} excludedLetter
*/
function createWithout(lettersExcludingLetter, excludedLetter) {
const type = ts.factory.createTypeReferenceNode('Without', [
ts.factory.createIntersectionTypeNode(
lettersExcludingLetter.map((letter) => ts.factory.createTypeReferenceNode(letter))
),
ts.factory.createTypeReferenceNode(excludedLetter)
])

return type
}

/**
* Takes a `symbolPool` and uses them solo and then matches them in pairs until
* the provided count of unique symbols is reached.
* If all possible pairs with the available symbols are already created and the
* `countPermsToGenerate` is still not reached, then triplets will start to be generated,
* then quadruplets, etc.
*
* @example
* ```ts
* getUniqueSymbolPermutationsGivenPool(['A', 'B'], 8)
* // ['A', 'B', 'AA', 'AB', 'BA', 'BB', 'AAA', 'AAB']
* ```
*
* @param {string[]} symbolPool
* @param {number} countPermsToGenerate
*/
function getUniqueSymbolPermutationsGivenPool(symbolPool, countPermsToGenerate) {
const generateItem = (index) => {
if (index < 0) {
return ''
}
const remainder = index % 20
return generateItem(Math.floor(index / 20) - 1) + symbolPool[remainder]
}

const result = Array.from({ length: countPermsToGenerate }, (_, i) => generateItem(i))

return result
}

const tempFile = ts.createSourceFile(
'temp.ts',
'',
ts.ScriptTarget.ESNext,
false, ts.ScriptKind.TS,
)
const printer = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,
omitTrailingSemicolon: true,
})

const xorTsFileContents = `
import type { EvalIfNotUnknown } from './evalIfNotUnknown.js'
import type { Prettify } from './prettify.js'
import type { Without } from './without.js'

${
printer.printNode(
ts.EmitHint.Unspecified,
ts.factory.createJSDocComment(
`Restrict using either exclusively the keys of \`T\` or \
exclusively the keys of \`U\`.\n\n\
No unique keys of \`T\` can be used simultaneously with \
any unique keys of \`U\`.\n\n@example\n\
\`\`\`ts\nconst myVar: XOR<{ data: object }, { error: object }>\n\`\`\`\n\n\
Supports from 2 up to ${xorParamCount} generic parameters.\n\n\
More: https://github.com/maninak/ts-xor/tree/master#description\n`
),
tempFile,
)
}
${
printer.printNode(ts.EmitHint.Unspecified, createXor(), tempFile)
}`

console.log(xorTsFileContents)
9 changes: 2 additions & 7 deletions test/control-std-union-without-xor/setup.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
interface A {
a: string
}

interface B {
b: string
}
interface A { a: string }
interface B { b: string }

export type A_OR_B = A | B
6 changes: 6 additions & 0 deletions test/four-xored-types/has-keys-of-A-and-C.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

// @ts-expect-error
const test: XOR_A_B_C_D = { a: '', c: '' }
// @ts-expect-error
const testNested: XOR_A_B_C_D_Nested = { a: '', c: '' }
6 changes: 6 additions & 0 deletions test/four-xored-types/has-keys-of-A-and-D.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

// @ts-expect-error
const test: XOR_A_B_C_D = { a: '', d: '' }
// @ts-expect-error
const testNested: XOR_A_B_C_D_Nested = { a: '', d: '' }
4 changes: 4 additions & 0 deletions test/four-xored-types/has-keys-of-A.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

const test: XOR_A_B_C_D = { a: '' } // OK
const testNested: XOR_A_B_C_D_Nested = { a: '' } // OK
4 changes: 4 additions & 0 deletions test/four-xored-types/has-keys-of-B.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

const test: XOR_A_B_C_D = { b: '' } // OK
const testNested: XOR_A_B_C_D_Nested = { b: '' } // OK
6 changes: 6 additions & 0 deletions test/four-xored-types/has-keys-of-C-and-D.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

// @ts-expect-error
const test: XOR_A_B_C_D = { c: '', d: '' }
// @ts-expect-error
const testNested: XOR_A_B_C_D_Nested = { c: '', d: '' }
4 changes: 4 additions & 0 deletions test/four-xored-types/has-keys-of-C.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

const test: XOR_A_B_C_D = { c: '' } // OK
const testNested: XOR_A_B_C_D_Nested = { c: '' } // OK
4 changes: 4 additions & 0 deletions test/four-xored-types/has-keys-of-D.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

const test: XOR_A_B_C_D = { d: '' } // OK
const testNested: XOR_A_B_C_D_Nested = { d: '' } // OK
6 changes: 6 additions & 0 deletions test/four-xored-types/has-no-keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { XOR_A_B_C_D, XOR_A_B_C_D_Nested } from './setup'

// @ts-expect-error
const test: XOR_A_B_C_D = {}
// @ts-expect-error
const testNested: XOR_A_B_C_D_Nested = {}
9 changes: 9 additions & 0 deletions test/four-xored-types/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { XOR } from '../../src'

interface A { a: string }
interface B { b: string }
interface C { c: string }
interface D { d: string }

export type XOR_A_B_C_D = XOR<A, B, C, D>
export type XOR_A_B_C_D_Nested = XOR<A, XOR<B, XOR<C,D>>>
4 changes: 0 additions & 4 deletions test/multiple-xored-types/has-keys-of-A-and-C.spec.ts

This file was deleted.

3 changes: 0 additions & 3 deletions test/multiple-xored-types/has-keys-of-A.spec.ts

This file was deleted.

3 changes: 0 additions & 3 deletions test/multiple-xored-types/has-keys-of-B.spec.ts

This file was deleted.

3 changes: 0 additions & 3 deletions test/multiple-xored-types/has-keys-of-C.spec.ts

This file was deleted.

4 changes: 0 additions & 4 deletions test/multiple-xored-types/has-no-keys.spec.ts

This file was deleted.

Loading