Skip to content
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
5 changes: 3 additions & 2 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,12 @@ jobs:
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- uses: pnpm/action-setup@v3
- uses: pnpm/action-setup@v4
with:
version: 8
version: 9.15.0
- run: pnpm install
- run: pnpm test
- run: pnpm check
- run: pnpm build
- run: npm publish
env:
Expand Down
15 changes: 8 additions & 7 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,21 @@ jobs:
fail-fast: false
matrix:
node-version:
- 24
- 22
- 20
- 18
- 16
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v4
with:
version: 8
version: 9.15.0
- run: pnpm install
- run: pnpm test
- uses: codecov/codecov-action@v4.0.1
- run: pnpm check
- uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: adrgautier/fffunction
176 changes: 97 additions & 79 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ![fffunction](./fffunction.svg)
![typescript](https://img.shields.io/badge/written%20for-typescript-3178c6?style=flat-square) [![codecov](https://img.shields.io/codecov/c/github/adrgautier/fffunction?style=flat-square&token=IPTGBDRRJE)](https://codecov.io/gh/adrgautier/fffunction) ![prettier](https://img.shields.io/badge/code%20style-prettier-ff69b4?style=flat-square) [![npm](https://img.shields.io/npm/v/fffunction?style=flat-square)](https://www.npmjs.com/package/fffunction)
![typescript](https://img.shields.io/badge/written%20for-typescript-3178c6?style=flat-square) ![biome](https://img.shields.io/badge/checked%20with-biome-60a5fa?style=flat-square) [![codecov](https://img.shields.io/codecov/c/github/adrgautier/fffunction?style=flat-square&token=IPTGBDRRJE)](https://codecov.io/gh/adrgautier/fffunction) [![npm](https://img.shields.io/npm/v/fffunction?style=flat-square)](https://www.npmjs.com/package/fffunction)

**fffunction** is a tool which simplifies **polymorphic** functions declaration and adds **type constraints** to the implemention function.

Expand All @@ -17,13 +17,13 @@ Here is an example of a function which return either a random `number` or a rand

```ts
const random = fffunction
.f<"number", number>()
.f<"string", string>()
.f(function ({ input, output }) {
if (input === "number") {
return output(Math.random());
.f<(type: "number") => number>()
.f<(type: "string") => string>()
.f(function ([check, type]) {
if (type === "number") {
return check(Math.random());
}
return output(uuidv4());
return check(uuidv4());
});
```

Expand All @@ -38,29 +38,35 @@ If the value `"string"` is provided, an uuid will be returned.
console.log(random("string")); // 425dd1a0-cfc0-4eac-a2d7-486860d9bdd4
```

The returned type **is guaranted** by the `output` function.
The returned type **is guaranted** by the `check` function.

# How to use

## Signatures declaration

### `.f<TInput, TOutput>()`
### `.f<TSignature>()`

Declaring a function signature is done by calling the `.f()` method with no argument and using the generic as follow:
Declaring a function signature is done by calling the `.f()` method with no argument by passing the function signature as follow:

```ts
fffunction
.f<"string", string>()
.f<(type: "string") => string>()
```

The **first argument** in the generic defines the accepted **input** type. The **second argument** defines the expected **return / output** type.

Signature declarations are queued like this:

```ts
fffunction
.f<"string", string>()
.f<"number", number>()
.f<(type: "string") => string>()
.f<(type: "number") => number>()
```

A signature can expect multiple arguments:

```ts
fffunction
.f<(type: "string", mode: "v4") => string>()
.f<(type: "number") => number>()
```

### Constraints
Expand All @@ -69,79 +75,96 @@ Input types can overlap each others, however the **most specific** input types m

```ts
fffunction
.f<{ id: number, name: string }, Profile>()
.f<{ id: number }, Item>()
.f<(i: { id: number, name: string }) => Profile>()
.f<(i: { id: number }) => Item>()
```

*fffunction* prevents declaring signatures in the **wrong order**:

```ts
fffunction
.f<{ id: number }, Item>()
.f<{ id: number, name: string }, Profile>()
^^^^^^^
// Type 'Profile' does not satisfy the constraint 'never'. ts(2344)
.f<(i: { id: number }) => Item>()
.f<(i: { id: number, name: string }) => Profile>()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Type 'Profile' is not assignable to type 'never'. ts(2344)
```

*Literals* **cannot overlap** each others:
*Primitive* types **cannot overlap** each others:

```ts
fffunction
.f<`https://${string}`, URL>()
.f<string, string>()
^^^^^^
// Type 'string' does not satisfy the constraint 'never'. ts(2344)
.f<(a: `https://${string}`) => URL>()
.f<(a: string) => string>()
^^^^^^^^^^^^^^^^^^^^^
// Type 'string' is not assignable to type 'never'. ts(2344)
```

This ensures that each input type can be **narrowed down** later in the function implementation.

### About generic function

You can provide a generic signature function to constrain the function arguments:
```ts
fffunction
.f<<TType extends string>(type: TType, content: { type: TType, description: string }) => string>()
```
However, it **should not** be used to declare the return type:
```ts
fffunction
.f<<TValue extends string>(value: TValue) => Promise<TValue>>()
```

This is for two reasons:
- the inference would only work in "overload mode"
- the implementation function **cannot check** if the returned value has the proper subset

## Function implementation

### `.f<TAdHoc>(implementation)`
### `.f<TMode>(implementation)`

The implementation of the function is based on the concept of [type narrowing](https://www.typescriptlang.org/docs/handbook/2/narrowing.html).

```ts
/*...*/
.f(function implementation({ input, output }) { /*...*/ })
.f(function implementation([check, arg1, arg2, ...args]) { /*...*/ })
```

The `implementation` function (named here for the example) will receive a `FFFArgument` object. This argument carries two informations :
- the input value on the `input` property
- the `output()` method (more on that later)
The `implementation` function (named here for the example) will receive a tuple. This argument carries:
- the "check" function that ensures the return value is narrowed down enough
- the argument(s) provided in the same order as declared in the signatures

### Narrowing type

In the main scope of the function, the type of the `input` property is **uncertain**. It can be either of the input types defined in the signatures. We want to create a narrowed scope for each possible type :
In the main scope of the function, the type of `arg` is **uncertain**. It can be either of the argument types defined in the signatures. We want to create a narrowed scope for each possible type:

```ts
function implementation ({ input, output }) {
// input is "number" | "string"
if (input === "number") {
// input is "number"
function implementation ([check, arg]) {
// arg is "number" | "string"
if (arg === "number") {
// arg is "number"
} else {
// input is "string"
// arg is "string"
}
})
```

### `output()`
### `check()`

Behind the scene, TypeScript is also able to narrow down the type of the `output()` method. This method will make sure the returned value **matches the expected output type**.
Behind the scene, TypeScript is also able to narrow down the type of the `check()` function. This function will make sure the returned value **matches the expected return type**.

```ts
function implementation ({ input, output }) {
if (input === "number") {
return output(1234);
function implementation ([check, arg]) {
if (arg === "number") {
return check(1234);
}
return output('test');
return check('test');
})
```

> This method is **mandatory**. You can't return any value without using this method.
> In fact, it must also be called for **void returns** :
> This function is **mandatory**. You can't return any value without using this method.
> In fact, it must also be called for **void returns**:
> ```ts
> return output();
> return check();
> ```


Expand All @@ -151,38 +174,37 @@ Out of the box, you will only be able to **narrow the input** type from *literal

```ts
fffunction
.f<{ id: number, name: string }, 'profile'>()
.f<{ id: number }, 'item'>()
.f(({ input, output }) => {
if('name' in input) {
return output('profile');
^^^^^^^^^
// Type 'string' does not satisfy the constraint 'never'. ts(2344)
.f<(i: { id: number, name: string }) => 'profile'>()
.f<(i: { id: number }) => 'item'>()
.f(([check, arg]) => {
if('name' in arg) {
return check('profile');
^^^^^^^^^
// Argument of type '"profile"' is not assignable to parameter of type 'never'. ts(2345)
}
return output('item');
return check('item');
});
```

If you need to work **with objects** in input, I recommand using **[ts-pattern](https://github.com/gvergnaud/ts-pattern)** to narrow down the input type:

```ts
fffunction
.f<{ id: number, name: string }, 'profile'>()
.f<{ id: number }, 'item'>()
.f((a) =>
match(a)
.with({ input: { name: P.string } }, ({ output }) => output('profile'))
.otherwise(({ output }) => output('item'))
.f<(i: { id: number, name: string }) => 'profile'>()
.f<(i: { id: number }) => 'item'>()
.f((u) =>
match(u)
.with([P._, { name: P.string }], ([check]) => check('profile'))
.otherwise(([check]) => check('item'))
);
```

### Optional "overload" mode

### Optional "ad hoc" mode

You can enable the **"ad hoc" mode** by passing true to the generic :
You can enable the **"overload" mode** by passing "overload" to the generic:

```ts
.f<true>(implementation);
.f<'overload'>(implementation);
```

This mode allow to declare the polymorphic function using [function overloading](https://www.typescriptlang.org/docs/handbook/2/functions.html#function-overloads) instead of conditional return type.
Expand All @@ -191,16 +213,13 @@ This mode allow to declare the polymorphic function using [function overloading]

This can make the resulting function easier to understand with each signature individially identifiable.


|Default|Ad hoc|
|Conditional (default)|Overloaded|
|-------|------|
|![conditional suggestion](./images/conditional_declaration.png)|![overload suggestion](./images/overload_declaration.png)|



#### Drawback

With this approach you loose the ability to call the function with uncertain input data. E.g. the following is not possible :
With this approach you loose the ability to call the function with uncertain input data. E.g. the following is not possible:

```ts
random(mode as "string" | "number"); // No overload matches this call.
Expand All @@ -212,34 +231,33 @@ With the above example, `mode` must be either `"string"` or `"number"`. The unce

## Signature declaration

### `TS2344: Type 'A' does not satisfy the constraint 'never'`
### `TS2344: Type 'A' is not assignable to type 'never'`

```ts
.f<"string", string>()
^^^^^^
.f<(a: "string") => string>()
^^^^^^^^^^^^^^^^^^^^^^^
```

That means input of two signatures are conflicting. See the **input overlapping** section above.


## Function implementation

### `TS2344: Type 'A' does not satisfy the constraint 'never'`
### `TS2345: Argument of type 'A' is not assignable to parameter of type 'never'`

```ts
return output(value);
^^^^^
return check(value);
^^^^^
```

The type `input` type has not been narrowed down enough or properly.
The arguments type has not been narrowed down enough or properly.


### `TS2322: Type 'A' is not assignable to type 'FFFOutput<A | B>'`
### `TS2322: Type 'A' is not assignable to type 'Checked<A | B>'`

```ts
return value;
^^^^^
```

You are trying to return a value without the `output` function.

You are trying to return a value without the `check` function.
Loading