Skip to content

Commit

Permalink
feat(ofType): narrow type of output based on action type
Browse files Browse the repository at this point in the history
  • Loading branch information
thynson committed May 26, 2021
1 parent 2b6c0ed commit d3b270c
Show file tree
Hide file tree
Showing 2 changed files with 130 additions and 1 deletion.
21 changes: 21 additions & 0 deletions src/operators.ts
Expand Up @@ -7,6 +7,21 @@ const keyHasType = (type: unknown, key: unknown) => {
return type === key || (typeof key === 'function' && type === key.toString());
};

/**
* Inferring the types of this is a bit challenging, and only works in newer
* versions of TypeScript.
*
* @param ...types One or more Redux action types you want to filter for, variadic.
*/
export function ofType<
// All possible actions your app can dispatch
Input extends Action,
// The literal types you want to filter for
Type extends Input['type'] & (string | number | symbol | boolean | object),
// The resulting actions that match the above types
Output extends Input = Extract<Input, Action<Type>>
>(...types: [Type, ...Type[]]): OperatorFunction<Input, Output>;

/**
* Inferring the types of this is a bit challenging, and only works in newer
* versions of TypeScript.
Expand All @@ -20,6 +35,12 @@ export function ofType<
Type extends Input['type'],
// The resulting actions that match the above types
Output extends Input = Extract<Input, Action<Type>>
>(...types: [Type, ...Type[]]): OperatorFunction<Input, Output>;

export function ofType<
Input extends Action,
Type extends Input['type'],
Output extends Input = Extract<Input, Action<Type>>
>(...types: [Type, ...Type[]]): OperatorFunction<Input, Output> {
const len = types.length;

Expand Down
110 changes: 109 additions & 1 deletion test/operators-spec.ts
@@ -1,8 +1,9 @@
import { expect } from 'chai';
import { Subject } from 'rxjs';
import { of, Subject } from 'rxjs';
import { ofType, __FOR_TESTING__resetDeprecationsSeen as resetDeprecationsSeen } from '../';
import { AnyAction } from 'redux';
import sinon from 'sinon';
import { map } from 'rxjs/operators';

describe('operators', () => {
describe('ofType', () => {
Expand Down Expand Up @@ -148,5 +149,112 @@ describe('operators', () => {
expect((console.warn as sinon.SinonSpy).callCount).to.equal(1);
expect((console.warn as sinon.SinonSpy).getCall(0).args[0]).to.equal('redux-observable | WARNING: ofType was called with one or more undefined or null values!');
});

it('should narrow type based for primitive type', () => {
enum StringEnum {
A = 'A',
B = 'B'
}

enum NumericEnum {
A = 100,
B = 200,
}

const symbolA: unique symbol = Symbol();
const symbolB = Symbol();

type StringLiteralAction = {
type: 'stringLiteralA';
stringLiteralA: string;
} | {
type: 'stringLiteralB';
stringLiteralB: string;
};

type NumericLiteralAction = {
type: 0;
numericLiteral0: string;
} | {
type: 1;
numericLiteral1: string;
}

type StringEnumAction = {
type: StringEnum.A;
stringEnumA: string;
} | {
type: StringEnum.B;
stringEnumB: string;
};

type NumericEnumAction = {
type: NumericEnum.A;
numericEnumA: string;
} | {
type: NumericEnum.B;
numericEnumB: string;
};

type SymbolAction = {
type: typeof symbolA;
symbolA: string;
} | {
type: typeof symbolB;
symbolB: string;
}

class Constructor {}

type SpecialAction = {
type: undefined;
undefined: string;
} | {
type: null;
null: string;
} | {
type: {foo: 'bar'};
objectLiteral: string;
} | {
type: true;
true: string;
} | {
type: false;
false: string;
} | {
type: typeof Constructor;
constructor: string;
};


type TestAction = StringLiteralAction | NumericLiteralAction | StringEnumAction | NumericEnumAction | SymbolAction | SpecialAction;

// This test only verify the following code can be compiled

of<TestAction>().pipe(ofType('stringLiteralA'), map((x) => x.stringLiteralA));
of<TestAction>().pipe(ofType('stringLiteralB'), map((x) => x.stringLiteralB));
of<TestAction>().pipe(ofType(0), map((x) => x.numericLiteral0));
of<TestAction>().pipe(ofType(1), map((x) => x.numericLiteral1));
of<TestAction>().pipe(ofType(StringEnum.A), map((x) => x.stringEnumA));
of<TestAction>().pipe(ofType(StringEnum.B), map((x) => x.stringEnumB));

// Maybe a bug of typescript: When type of Action contains both numeric literal and numeric enum,
// ofType cannot narrow for numeric enum type, so the following does not compile
//
// of<TestAction>().pipe(ofType(NumericEnum.A), map((x) => x.value6));
// of<TestAction>().pipe(ofType(NumericEnum.B), map((x) => x.value7));
//
// But the following code compiles
of<Exclude<TestAction, NumericLiteralAction>>().pipe(ofType(NumericEnum.A), map((x => x.numericEnumA)));
of<Exclude<TestAction, NumericLiteralAction>>().pipe(ofType(NumericEnum.B), map((x => x.numericEnumB)));

of<TestAction>().pipe(ofType(symbolA), map((x) => x.symbolA));
of<TestAction>().pipe(ofType(symbolB), map((x) => x.symbolB));
of<TestAction>().pipe(ofType({ foo: 'bar' }), map((x) => x.objectLiteral));
of<TestAction>().pipe(ofType(undefined), map((x) => x.undefined));
of<TestAction>().pipe(ofType(true), map((x) => x.true));
of<TestAction>().pipe(ofType(false), map((x) => x.false));
of<TestAction>().pipe(ofType(Constructor), map((x) => x.constructor));
});
});
});

0 comments on commit d3b270c

Please sign in to comment.