Skip to content

Instantly share code, notes, and snippets.

@MikeBild
Created November 10, 2016 10:26
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MikeBild/ad011e8cdb09c015ee91dcdc136b58b1 to your computer and use it in GitHub Desktop.
Save MikeBild/ad011e8cdb09c015ee91dcdc136b58b1 to your computer and use it in GitHub Desktop.
Q&A - I wanna understand TypeScript - Part TypeScript Union Types as Redux Action Tags
// demonstrate
const simulateActions = [
addTodo({text: 'todo 1'}),
addTodo({text: 'todo 2'}),
toggleTodo({index: 0}),
toggleTodo({index: 1}),
];
const actual = simulateActions.reduce(todosReducer, []);
console.log(actual);
// https://blog.mariusschulz.com/2016/11/03/typescript-2-0-tagged-union-types
const ADD_TODO = 'ADD_TODO';
const TOGGLE_TODO = 'TOOGLE_TODO';
const todo = (type, payload) => ({type, payload});
const addTodo = payload => todo(ADD_TODO, payload);
const toggleTodo = payload => todo(TOGGLE_TODO, payload);
const todosReducer = (state, action) => {
switch (action.type) {
case ADD_TODO:
return [...state, { text: action.payload.text, done: false }];
case TOGGLE_TODO:
return state.map((todo, index) => {
if (index !== action.payload.index) return todo;
return {
text: todo.text,
done: !todo.done,
};
});
default:
return state;
}
}
@mariusschulz
Copy link

mariusschulz commented Nov 10, 2016

If you migrate your todos-reducer.js file to TypeScript, you'll have plenty of local variables and parameters implicitly typed as any. Therefore, the TypeScript compiler and language service can only assist you to a certain degree. In order to fully leverage type checking and benefit from tooling such as autocompletion, you need some explicit types. To loosely quote Anders: you gotta say something about something, somewhere.

The main problems I have with the plain JavaScript example lie within the payload property of the Redux actions:

  • Type Checking: TypeScript helps you make sure you return a state object of the correct shape, that is with all properties of the correct type.
  • Refactoring: What if you want to rename a property, e.g. done to completed? TypeScript lets you do that quickly and safely.
  • Discoverability: How do you know what properties the payload property has for ADD_TODO? How about TOGGLE_TODO? Do you learn them by heart or look them up every time?

A union type of all Redux actions, combined with control flow analysis and tagged union types (aka discriminated union types), allows you to write code following the common JavaScript (and Redux) idioms. There's no need to introduce type assertions; inspecting the discriminant property of a tagged union type narrows the type within the specific code path. Due to control flow analysis, it all "just works".

The benefit of using string literal types for the Redux actions' type property is that you don't have to import your ADD_TODO constants in various reducer files. You can simply use the string literal "ADD_TODO" directly without losing type safety — string literal types can be refactored; they're not "just strings" that the compiler doesn't understand.

@MikeBild
Copy link
Author

Thank you for comment Marius.

Compressed: your pain with plain JavaScript is the missing professional development tool chain to support autocompletion, type checking at development time, refactoring tools, etc. Right? Your thought is, you can produce "rock solid code" that "just works" by using the additional concept of ADTs. That's fine. But remember, you add in addition, in this case unnecessary, complexity into your application only to compensate missing dev tools. In my opinion, that's not good. That's risky.

Yes, I like the easiness of "the most things are things". Name and use it as any. Mostly that's is what it is. Your user is doing something (action) and I'll handle something to accumulate a new state. The "magic string" doesn't match? That's fine for me. I'll catch missing functionality, for instance magic string doesn't match, in my unit and integration tests that running continuously in background. BTW: like the -very slow- TypeScript "server" ;-)

My thoughts about your understandable arguments:

  • Type Checking: Input(any) -> Process -> Output(any) every functional test will break by breaking this rule
  • Refactoring: I use multicursor support and find / replace. Works very good-
  • Discoverability: I know wich property an input has, or I use console.log

The TypeScript feature "control flow analysis and tagged union types (aka discriminated union types)" is very nice! Well, as you shown in my example: you ain't gonna need it. Really, keep it simple.

My personal rules are:

  • don't trust magic tools blindly
  • understand the concepts behind
  • reduce unnecessary complexity - simplify all the things
  • work behavior-driven, not Type (ADT) driven (verbs vs. nouns)
  • make failure cases explicit
  • write "progressive" functional code

The motto "type all things" and you have a nice tool chain is very tempting. I can understand that, but -again- remember it's not a free lunch. Prefering "type celebration" by adding more "boilerplate code" only to support better transpiler/compiler, inference magic, refactoring and some better discoverability are IMHO not good reasons to use TypeScript-First.

@mariusschulz
Copy link

mariusschulz commented Nov 10, 2016

Allow me to rebut some of your points.

No doubt, I agree with you on the value of unit and integration tests. However, tests should test application logic, not check for typos or the existence of a property of a "thing", as you aptly call it. Tests are not a replacement for a good type system. Both should complement each other.

(As the saying goes: each sufficiently large test suite contains an ad-hoc, bug-ridden, slow implementation of a type system.)

That said, I'm not arguing that one cannot write well-tested, working software without static types. What I am arguing is that tooling and type checking helps do exactly that. Used correctly, features like non-nullable types can eradicate entire categories of bugs at compile-time.

Here's my take on your list of personal rules:

  • "don't trust magic tools blindly" — A good point, though TypeScript is far from a magic tool; in fact, it's quite the opposite.
  • "understand the concepts behind" — I agree 100%.
  • "reduce unnecessary complexity" — You write a test, I add a type annotation. The complexity of each is debatable.
  • "work behavior-driven, not Type (ADT) driven (verbs vs. nouns)" — A good type system doesn't get in your way and allows you to do just that.
  • "make failure cases explicit" — Precisely! Encode them in the type system and force the consumer to deal with them (see this example).
  • "write 'progressive' functional code" — TypeScript allows that too, of course.

I agree, adding TypeScript to a project is not a free lunch; no tool is. But it's not a huge deal, either. However, I neither celebrate type annotations nor boilerplate code, as you put it. The fewer explicit annotations I have to add myself, the better. That's why I'm such a fan of the new control flow analyses implemented in the compiler. Knowledge of types allows the type checker and the language service to aid you.

I'm not trying to force TypeScript onto you. Use whatever makes you happy and productive! I just don't agree with wording such as "inference magic". Out of many languages I've used, the compilation from TypeScript to JavaScript is the least surprising I've encountered.

@MikeBild
Copy link
Author

Agree! I'm not trying to force plain Javascript onto you. Unisono - perfect! I think TypeScript is a good migration language for people there are have a deep affinity to the "Type/Data-Driven" approach. In this case, let's start with TypeScript. A good recommendation going deeper into a weak typed language.

Last thought related to your newly mentioned TypeScript features, because there are very powerful concepts without using transpiler checked contraints based on types.

non-nullable types - yeah nice, but you can und IMHO you should use a more semantical construct named maybe / optional. The interesting part is, you wrote an article about the the same concept to handle failures. We both like it, so why not consistently apply this concept to possible failures like this?

@mariusschulz
Copy link

Types like Result<T>, Maybe<T>, Optional<T>, or Either<TLeft, TRight> all make sense in their own way. However, I find simple sum types to be more lightweight to express using plain union types. For instance, a method that returns either a number or nothing can be typed as follows:

function tryParseInt(input: string): number | null {
    return /^\d+$/.test(input)
        ? parseInt(input, 10)
        : null;
}

The type number | null communicates clearly what the tryParseInt function can return. I don't see a lack of semantic meaning here. Of course, we could instead create a wrapper object:

type Optional<T> =
    { hasValue: true, value: T } |
    { hasValue: false };

function tryParseInt(input: string): Optional<number> {
    return /^\d+$/.test(input)
        ? { success: true, value: parseInt(input, 10) }
        : { success: false };
}

I still favor the union type. It reduces unnecessary complexity, a goal we both want to achieve.

@GregOnNet
Copy link

GregOnNet commented Nov 11, 2016

Hey guys,

you may want to have a look on @mhoyer's project redux-typed-ducks.
The idea is to wrap Actions in a context (action-creator-function) providing better typing-support.
It builds an abstraction layer on top of Actions.

@MikeBild
Copy link
Author

@marius

I prefer an tech/language agnostic construct either(), because you can do it any "lambda" enabled language.

function either(f, g) {
	return function () {
		return f.apply(this, arguments) || g.apply(this, arguments);
	}
}

const gt10 = x => x > 10;
const even = x => x % 2 === 0;
const f = either(gt10, even);

console.log(f(7))
console.log(f(100))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment