Skip to content

Add TypeScript definitions to enforce or infer whether properties are required. #114

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

Merged
merged 10 commits into from
Apr 14, 2019
Merged
8 changes: 8 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module.exports = {
transform: {
'^.+\\.js$': 'babel-jest',
'^.+\\.tsx?$': 'ts-jest'
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node']
};
8 changes: 6 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,21 @@
"@babel/cli": "^7.2.3",
"@babel/core": "^7.3.4",
"@babel/preset-env": "^7.3.4",
"@types/jest": "^24.0.11",
"@typescript-eslint/eslint-plugin": "^1.5.0",
"babel-jest": "^24.5.0",
"babel-plugin-lodash": "^3.3.4",
"eslint": "^5.0.0",
"eslint-config-jest-files": "^0.1.3",
"eslint-config-typescript-basic": "^1.0.1",
"eslint-config-unicorn-camelcase": "^0.1.1",
"eslint-plugin-prettier": "^3.0.1",
"eslint-plugin-typescript": "^0.14.0",
"husky": "^0.14.3",
"jest": "^24.5.0",
"prettier": "^1.16.4",
"pretty-quick": "^1.10.0",
"rimraf": "^2.6.3",
"ts-jest": "^24.0.1",
"typescript": "^3.3.3333",
"typescript-eslint-parser": "^22.0.0",
"xo": "^0.24.0"
Expand All @@ -67,7 +70,8 @@
"prettier": {
"singleQuote": true,
"bracketSpacing": true,
"trailingComma": "none"
"trailingComma": "none",
"endOfLine": "auto"
},
"jest": {
"collectCoverage": true,
Expand Down
223 changes: 208 additions & 15 deletions src/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
export interface FluxStandardAction<Payload, Meta = undefined> {
/**
* A Flux Standard action with optional payload and metadata properties.
*/
export interface FluxStandardAction<
Payload = undefined,
Meta = undefined,
Type extends string = string
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious what the benefit of creating Type and using the extends keyword is, as opposed to just saying it must be a string. I'm not up to speed on the latest TS trends 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TypeScript you can restrict a type to a constant value.

For a Counter application example this makes it possible to change the following code:

import { FSA } from 'flux-standard-action';

const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
type INCREMENT_COUNTER = typeof INCREMENT_COUNTER;
interface IncrementCounterAction extends FSA {
  type: INCREMENT_COUNTER;
}

to this:

import { FSA } from 'flux-standard-action';

const INCREMENT_COUNTER = 'INCREMENT_COUNTER';
type IncrementCounterAction = FSA<undefined, undefined, typeof INCREMENT_COUNTER>;

removing the need to re-declare the type property. An object using a string, but different to 'INCREMENT_COUNTER' would thus not implement the IncrementCounterAction type.

Copy link

@hally9k hally9k Apr 12, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@couven92 Why would we want to put the Type generic to be last? This would mean we would always have to explicitly type meta whether the action has meta or not.
I think I would prefer to put tType first as it is the primary property of an FSA and I want to always strongly type my action types for discriminitive unions in redux reducers. Where as quite a lot of my action don't utilise the Meta property.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My opinionated take on this is on the master branch of this fork if you want to have a look https://github.com/hally9k/flux-standard-action

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

> {
/**
* The `type` of an action identifies to the consumer the nature of the action that has occurred.
* Two actions with the same `type` MUST be strictly equivalent (using `===`)
*/
type: string;
type: Type;
/**
* The optional `payload` property MAY be any type of value.
* It represents the payload of the action.
Expand All @@ -26,36 +33,222 @@ export interface FluxStandardAction<Payload, Meta = undefined> {
meta?: Meta;
}

/**
* An extension of the Flux Standard action that represents an action containing an error as its payload.
*/
export interface ErrorFluxStandardAction<
CustomError extends Error,
Meta = undefined
> extends FluxStandardAction<CustomError, Meta> {
CustomError extends Error = Error,
Meta = undefined,
Type extends string = string
> extends FluxStandardAction<CustomError, Meta, Type> {
/**
* The required `error` property MUST be set to `true` if the action represents an error.
*/
error: true;
}

/**
* Alias for FluxStandardAction.
*/
export type FSA<Payload, Meta = undefined> = FluxStandardAction<Payload, Meta>;
export type FSA<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardAction<Payload, Meta, Type>;

/**
* Alias for ErrorFluxStandardAction.
*/
export type ErrorFSA<
CustomError extends Error,
Meta = undefined
> = ErrorFluxStandardAction<CustomError, Meta>;
CustomError extends Error = Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardAction<CustomError, Meta, Type>;

/**
* Returns `true` if `action` is FSA compliant.
*/
export function isFSA<Payload, Meta = undefined>(
action: any
): action is FluxStandardAction<Payload, Meta>;
export function isFSA<
Payload = undefined,
Meta = undefined,
Type extends string = string
>(action: any): action is FluxStandardAction<Payload, Meta, Type>;

/**
* Returns `true` if `action` is FSA compliant error.
*/
export function isError<CustomError extends Error, Meta = undefined>(
action: any
): action is ErrorFluxStandardAction<CustomError, Meta>;
export function isError<
CustomError extends Error = Error,
Meta = undefined,
Type extends string = string
>(action: any): action is ErrorFluxStandardAction<CustomError, Meta, Type>;

/**
* A Flux Standard action with a required payload property.
*/
export interface FluxStandardActionWithPayload<
Payload = undefined,
Meta = undefined,
Type extends string = string
> extends FluxStandardAction<Payload, Meta, Type> {
/**
* The required `payload` property MAY be any type of value.
* It represents the payload of the action.
* Any information about the action that is not the type or status of the action should be part of the `payload` field.
* By convention, if `error` is `true`, the `payload` SHOULD be an error object.
* This is akin to rejecting a promise with an error object.
*/
payload: Payload;
}
/**
* Alias for FSAWithPayload
*/
export type FSAWithPayload<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardActionWithPayload<Payload, Meta, Type>;

/**
* A Flux Standard action with a required metadata property.
*/
export interface FluxStandardActionWithMeta<
Payload = undefined,
Meta = undefined,
Type extends string = string
> extends FluxStandardAction<Payload, Meta, Type> {
/**
* The required `meta` property MAY be any type of value.
* It is intended for any extra information that is not part of the payload.
*/
meta: Meta;
}
/**
* Alias for FluxStandardActionWithMeta
*/
export type FSAWithMeta<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardActionWithMeta<Payload, Meta, Type>;

/**
* A Flux Standard action with required payload and metadata properties.
*/
export type FluxStandardActionWithPayloadAndMeta<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardActionWithPayload<Payload, Meta, Type> &
FluxStandardActionWithMeta<Payload, Meta, Type>;
/**
* Alias for FluxStandardActionWithPayloadAndMeta
*/
export type FSAWithPayloadAndMeta<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardActionWithPayloadAndMeta<Payload, Meta, Type>;

/**
* A Flux Standard action with inferred requirements for the payload and metadata properties.
* The `payload` and `meta` properties will be required if the corresponding type argument
* if not the `undefined` type.
*/
export type FluxStandardActionAuto<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = Payload extends undefined
? (Meta extends undefined
? FluxStandardAction<Payload, Meta, Type>
: FluxStandardActionWithMeta<Payload, Meta, Type>)
: (Meta extends undefined
? FluxStandardActionWithPayload<Payload, Meta, Type>
: FluxStandardActionWithPayloadAndMeta<Payload, Meta, Type>);
/**
* Alias for FluxStandardActionAuto
*/
export type FSAAuto<
Payload = undefined,
Meta = undefined,
Type extends string = string
> = FluxStandardActionAuto<Payload, Meta, Type>;

/**
* A Flux Standard Error Action with a required payload property.
*/
export type ErrorFluxStandardActionWithPayload<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardAction<CustomError, Meta, Type> &
FluxStandardActionWithPayload<CustomError, Meta, Type>;
/**
* Alias for ErrorFluxStandardActionWithPayload
*/
export type ErrorFSAWithPayload<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardActionWithPayload<CustomError, Meta, Type>;

/**
* A Flux Standard Error Action with a required metadata property.
*/
export type ErrorFluxStandardActionWithMeta<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardAction<CustomError, Meta, Type> &
FluxStandardActionWithMeta<CustomError, Meta, Type>;
/**
* Alias for ErrorFluxStandardActionWithMeta
*/
export type ErrorFSAWithMeta<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardActionWithMeta<CustomError, Meta, Type>;

/**
* A Flux Standard Error Action with required payload and metadata properties.
*/
export type ErrorFluxStandardActionWithPayloadAndMeta<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardActionWithPayload<CustomError, Meta, Type> &
ErrorFluxStandardActionWithMeta<CustomError, Meta, Type>;
/**
* Alias for ErrorFluxStandardActionWithPayloadAndMeta
*/
export type ErrorFSAWithPayloadAndMeta<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardActionWithPayloadAndMeta<CustomError, Meta, Type>;

/**
* A Flux Standard Error action with inferred requirements for the payload and metadata properties.
* The `payload` and `meta` properties will be required if the corresponding type argument
* if not the `undefined` type.
*
* Note: The `payload` property will always be required, since the `CustomError` type argument does
* not allow for specification of the `undefined` type.
*/
export type ErrorFluxStandardActionAuto<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = Meta extends undefined
? ErrorFluxStandardActionWithPayload<CustomError, Meta, Type>
: ErrorFluxStandardActionWithPayloadAndMeta<CustomError, Meta, Type>;
/**
* Alias for ErrorFluxStandardActionAuto
*/
export type ErrorFSAAuto<
CustomError extends Error,
Meta = undefined,
Type extends string = string
> = ErrorFluxStandardActionAuto<CustomError, Meta, Type>;
16 changes: 16 additions & 0 deletions test/fsaAuto.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { FSAAuto, isFSA } from '../src';

describe('Usage of FSAAuto (automatically infer required properties', () => {
it('must specify payload property even when using a union with undefined', () => {
const fsa_with_payload = { type: 'TEST', payload: undefined };
expectOptionalPayload(fsa_with_payload);

const fsa_without_payload = { type: 'TEST' };
// Not possible to cast!!!
// expectOptionalPayload(fsa_without_payload);

function expectOptionalPayload(fsa: FSAAuto<string | undefined>) {
expect(fsa.payload).toBeUndefined();
}
});
});
23 changes: 23 additions & 0 deletions test/typeFSA.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { FSA } from '../src';

const ACTION_TYPE_1 = 'ACTION_TYPE_1';
type ACTION_TYPE_1 = typeof ACTION_TYPE_1;
type FSA_ACTION_TYPE_1 = FSA<undefined, undefined, ACTION_TYPE_1>;

const assertNever = (x: never): never => {
throw new Error(`Unexpected value: ${x}.`);
};

const assertTypeValue = (fsa: FSA_ACTION_TYPE_1) => {
expect(fsa.type).toBe(ACTION_TYPE_1);
};

describe('FluxStandardAction<Payload, Meta, Type>', () => {
it('enables TypeScript action type enforcement', () => {
const fsa_strict: FSA_ACTION_TYPE_1 = { type: ACTION_TYPE_1 };
assertTypeValue(fsa_strict);
if (fsa_strict.type !== ACTION_TYPE_1) {
throw assertNever(fsa_strict.type);
}
});
});
4 changes: 1 addition & 3 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,5 @@
"strictNullChecks": true,
"target": "es5"
},
"files": [
"test/typings.test.ts"
]
"files": ["src/index.d.ts"]
}
Loading