Skip to content

Commit 6234655

Browse files
committed
A little cleanup.
1 parent fc198e7 commit 6234655

File tree

11 files changed

+252
-162
lines changed

11 files changed

+252
-162
lines changed

README.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,54 @@
11
# TypeScript Pseudo Dependent Typing Example with Mobx & React
22

3+
# Static Typing Stragegies
4+
## Discriminated Unions
5+
### Representing Field States
6+
A common idiom in View/View Model architectures is to represent an editable field's visibility and read-only state with two independent properties on the View Model. For example, a firstName property that may be invisible in some contexts and read-only in other contexts may be represented with three separate properties on the view model:
7+
* firstName - a read/write property to which the view two-way data-binds.
8+
* isFirstNameVisible - a read-only property that may change in time.
9+
* isFirstNameReadOnly - a read-only property that may change in time.
10+
11+
This information representation is fraught with opportunities for improper usage/access such as
12+
* Reading the firstName property when not isFirstNameVisible.
13+
* Writing the firstName property when isFirstNameReadOnly.
14+
15+
The view model may protect itself from inappropriate access by throwing exceptions but this is terribly opaque and leads to the likelihood of run-time faults.
16+
17+
Run-time faults can be avoided altogether by employing Discriminated Unions to represent the valid states in such a way that meaning and usage are explicit and compiler verifiable.
18+
19+
Consider instead the Mobx code below
20+
```TypeScript
21+
@computed firstName: null | string | (firstName?: string) => string;
22+
```
23+
24+
The property is computed because it changes reactively based on the state of the editor.
25+
26+
The type signature clearly indicates three possible cases
27+
* null - indicates that the field is not available. This corresponds to invisible.
28+
* string - denotes a read-only state of the field. The user can read but not modify.
29+
* (firstName?: string) => string - represents a getter/setter function which can be used to both read and write the value.
30+
31+
By using a discriminated union the TypeScript compiler can guarantee that developers cannot expose a hidden field or allow mutation of a read-only field (without intentionally subverting the type system).
32+
33+
### Representing Actions
34+
Similarly, the common idiom for representing an action in a View Model is to provide a method on the view model for the action and a read-only property that indicates whether the action is available or enabled at the current time.
35+
36+
Once again, when modeled this way the type system cannot stop code from attempting to execute the method. Developers only recourse is to check for validity inside the method and throw an exception.
37+
38+
This can be modeled in a type-safe fashion with
39+
```TypeScript
40+
@computed doAwesomeThing: null | () => void;
41+
```
42+
43+
44+
## Dependent Typing - Cases By Values
45+
Object oriented code is no stranger to representing Cases with data types (the [State Pattern]( https://www.geeksforgeeks.org/state-design-pattern/) concerns itself explicitly with such concerns). A class is a "classificaiton" of thing; a set with members (object instances of the class).
46+
47+
An OO class in a nominal type system is
48+
* A named type where the name has semantic significance i.e. significance is related to the name.
49+
* A _structure_ of data with accompanying _behaviors_.
50+
51+
_Work in progress (i.e. more to come)_
352

453

554
# Installation

src/api/models/fn.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/** Represents a side-effecting function. */
2+
export type ThunkAction = () => void;
3+
4+
/** Represents a side-effecting routine accepting a single argument. */
5+
export type Action<T> = (input: T) => void;

src/api/models/numbers.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ export const sumOfCardinal = <TLeft extends Zero | NonZero, TRight extends Zero
3838

3939
export const sum = (left: number, right: number): number =>
4040
left + right;
41+
42+
export const sumArray = (nums: number[]): number => nums.reduce(sum, 0);

src/api/views/button.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as React from 'react';
2+
import { ThunkAction } from "../models/fn";
3+
4+
/** Create a button that is conditionally [dis/en]abled based upon when the action is available. */
5+
export const buttonForAction = (caption: string, action: null | ThunkAction) =>
6+
action === null
7+
? <button disabled={true}>{caption}</button>
8+
: <button onClick={action}>{caption}</button>;
9+

src/models/add_todo.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,33 @@
11
import { observable, action, computed } from 'mobx';
2+
import { ThunkAction } from "../api/models/fn";
23

3-
/**
4-
* Represents the user interaction when adding a To-Do.
5-
*/
4+
/** Represents the composite user interaction of adding a To-Do. */
65
class AddTodo {
76

8-
@observable
9-
private _taskName = "";
7+
@observable private _taskName = "";
108

119
constructor(private _injected: {
10+
/** Injected strategy for adding a to-do. */
1211
addIncompleteTodo: (taskName: string) => void;
1312
}) {}
1413

15-
public get taskName() { return this._taskName; }
14+
public get taskName(): string {
15+
return this._taskName;
16+
}
1617

17-
@action
18-
public updateTaskName(taskName: string) {
18+
/** Update the candidate todo task name. When populated with more than whitespace the user will have the option to add a todo with with name. */
19+
@action public updateTaskName(taskName: string): void {
1920
this._taskName = taskName;
2021
}
2122

22-
/**
23-
* The action to add a to-do is conditionally available based on the state of the task.
24-
*/
25-
@computed
26-
public get addTodoMaybe() {
23+
/** The action to add a to-do is conditionally available when the taskName is sufficiently populated. */
24+
@computed public get addTodoMaybe(): null | ThunkAction {
2725
return (this._taskName || "").trim().length > 0
2826
? () => this._addTodo()
2927
: null;
3028
}
3129

32-
@action
33-
private _addTodo() {
30+
@action private _addTodo(): void {
3431
this._injected.addIncompleteTodo(this._taskName);
3532
this._taskName = "";
3633
}

src/models/todo.transitions.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { succ } from "../api/models/numbers";
2+
import {
3+
Todo,
4+
TodoTimeClock,
5+
NeverStartedTodo,
6+
InProgressTodo,
7+
CompleteTodo,
8+
PausedTodo,
9+
totalMinutesLoggedForTimeClock,
10+
} from "./todo";
11+
12+
let _id = 1;
13+
/** Create a new Todo in a paused state. */
14+
export function create(taskName: string): NeverStartedTodo {
15+
return {
16+
id: _id++,
17+
taskName,
18+
minutesLoggedHistorically: 0,
19+
todoTimeClock: null,
20+
isComplete: false,
21+
pauseCount: 0
22+
};
23+
}
24+
25+
/** Represents the result of transitioning a Todo from one state to the next. */
26+
export type TodoStateTransition<TResultTodo extends Todo, TTimeClock extends null | TodoTimeClock> = {
27+
todo: TResultTodo;
28+
todoTimeClock: TTimeClock;
29+
}
30+
31+
/** Complete this in-progress todo. */
32+
export function complete({ currentTime, todo }: {
33+
currentTime: Date;
34+
todo: InProgressTodo;
35+
}): TodoStateTransition<CompleteTodo, null> {
36+
return {
37+
todoTimeClock: null,
38+
todo: {
39+
...todo,
40+
minutesLoggedHistorically: totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime }),
41+
todoTimeClock: null,
42+
isComplete: true
43+
}
44+
};
45+
}
46+
47+
/** Pause this in-progress todo. */
48+
export function pause({ currentTime, todo }: {
49+
currentTime: Date;
50+
todo: InProgressTodo;
51+
}): TodoStateTransition<PausedTodo, null> {
52+
return {
53+
todoTimeClock: null,
54+
todo: {
55+
...todo,
56+
todoTimeClock: null,
57+
pauseCount: succ(todo.pauseCount),
58+
minutesLoggedHistorically: totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime })
59+
}
60+
}
61+
}
62+
63+
/** Start a Todo (transition to In-Progress) that is Paused or Never Started. */
64+
export function start({ todo }: {
65+
todo: PausedTodo | NeverStartedTodo;
66+
todoTimeClock: null;
67+
}): TodoStateTransition<InProgressTodo, TodoTimeClock> {
68+
let inProgressTodo: InProgressTodo;
69+
const todoTimeClock: TodoTimeClock = {
70+
startTime: new Date(),
71+
todo: () => inProgressTodo
72+
}
73+
inProgressTodo = {
74+
...todo,
75+
todoTimeClock
76+
};
77+
78+
return { todoTimeClock, todo: inProgressTodo };
79+
}

src/models/todo.ts

Lines changed: 27 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,45 @@
11
import { Zero, NonZero, succ } from "../api/models/numbers";
22
import { minutesElapsed } from "../api/models/date";
33

4-
/**
5-
* A running clock for a Todo item.
6-
*/
4+
/** A Todo item - which can be in one of four states/classifications. */
5+
export type Todo =
6+
NeverStartedTodo
7+
| PausedTodo
8+
| InProgressTodo
9+
| CompleteTodo;
10+
11+
/** Running clock for a Todo item. */
712
export type TodoTimeClock = {
8-
/** */
913
todo(): InProgressTodo;
1014
readonly startTime: Date;
1115
}
1216

17+
/** Returns the total minutes for the timeclock entry which includes the historical todo minutes logged and the current elapsed time on the timer. */
1318
export const totalMinutesLoggedForTimeClock = ({ currentTime, todoTimeClock }: {
1419
currentTime: Date;
1520
todoTimeClock: TodoTimeClock;
1621
}): number =>
17-
todoTimeClock.todo().minutesLogged
18-
+ minutesElapsed({ currentTime, startTime: todoTimeClock.startTime });
22+
todoTimeClock.todo().minutesLoggedHistorically
23+
+ minutesElapsed({ currentTime, startTime: todoTimeClock.startTime });
1924

20-
export type BaseTodo = Readonly<{
25+
/** Return the minutes logged for the todo. */
26+
export const minutesLogged = ({ currentTime, todo }: {
27+
currentTime: Date;
28+
todo: Todo;
29+
}): number =>
30+
isInProgress(todo)
31+
? totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime })
32+
: todo.minutesLoggedHistorically;
33+
34+
/** Properties common to all Todo representations. */
35+
type BaseTodo = Readonly<{
2136
id: number;
2237
taskName: string;
23-
minutesLogged: number;
38+
minutesLoggedHistorically: number;
2439
pauseCount: Zero | NonZero;
2540
}>
2641

42+
/** A Todo item that is incomplete, not currently being work, and has never been paused. */
2743
export type NeverStartedTodo = BaseTodo & Readonly<{
2844
todoTimeClock: null;
2945
isComplete: false;
@@ -35,6 +51,7 @@ export function isNeverStarted(todo: Todo): todo is NeverStartedTodo {
3551
&& !todo.isComplete;
3652
}
3753

54+
/** A Todo item that is incomplete, not currently being worked, and has been paused at some point. */
3855
export type PausedTodo = BaseTodo & Readonly<{
3956
todoTimeClock: null;
4057
isComplete: false;
@@ -47,6 +64,7 @@ export function isPaused(todo: Todo): todo is PausedTodo {
4764
&& !todo.isComplete;
4865
}
4966

67+
/** A Todo item that is incomplete and currently being worked. */
5068
export type InProgressTodo = BaseTodo & Readonly<{
5169
todoTimeClock: TodoTimeClock;
5270
isComplete: false;
@@ -57,6 +75,7 @@ export function isInProgress(todo: Todo): todo is InProgressTodo {
5775
&& !todo.isComplete;
5876
}
5977

78+
/** A Todo item that is complete. */
6079
export type CompleteTodo = BaseTodo & Readonly<{
6180
todoTimeClock: null;
6281
isComplete: true;
@@ -65,81 +84,3 @@ export type CompleteTodo = BaseTodo & Readonly<{
6584
export function isComplete(todo: Todo): todo is CompleteTodo {
6685
return todo.isComplete;
6786
}
68-
69-
export type Todo = NeverStartedTodo | PausedTodo | InProgressTodo | CompleteTodo;
70-
71-
let _id = 1;
72-
export function create(taskName: string): NeverStartedTodo {
73-
return {
74-
id: _id++,
75-
taskName,
76-
minutesLogged: 0,
77-
todoTimeClock: null,
78-
isComplete: false,
79-
pauseCount: 0
80-
};
81-
}
82-
83-
export function complete({ currentTime, todo }: {
84-
currentTime: Date;
85-
todo: InProgressTodo;
86-
}): {
87-
todo: CompleteTodo;
88-
todoTimeClock: null;
89-
} {
90-
return {
91-
todoTimeClock: null,
92-
todo: {
93-
...todo,
94-
minutesLogged: totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime }),
95-
todoTimeClock: null,
96-
isComplete: true
97-
}
98-
};
99-
}
100-
101-
export function pause({ currentTime, todo }: {
102-
currentTime: Date;
103-
todo: InProgressTodo;
104-
}): {
105-
todoTimeClock: null;
106-
todo: PausedTodo;
107-
} {
108-
return {
109-
todoTimeClock: null,
110-
todo: {
111-
...todo,
112-
todoTimeClock: null,
113-
pauseCount: succ(todo.pauseCount),
114-
minutesLogged: totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime })
115-
}
116-
}
117-
}
118-
119-
export function start({ todo }: {
120-
todo: PausedTodo | NeverStartedTodo;
121-
todoTimeClock: null;
122-
}): {
123-
todoTimeClock: TodoTimeClock;
124-
todo: InProgressTodo;
125-
} {
126-
let inProgressTodo: InProgressTodo;
127-
const todoTimeClock: TodoTimeClock = {
128-
startTime: new Date(),
129-
todo: () => inProgressTodo
130-
}
131-
inProgressTodo = {
132-
...todo,
133-
todoTimeClock
134-
};
135-
136-
return { todoTimeClock, todo: inProgressTodo };
137-
}
138-
139-
export const minutesLoggedForTodo = ({ currentTime, todo }: {
140-
currentTime: Date;
141-
todo: Todo;
142-
}): number =>
143-
isInProgress(todo)
144-
? totalMinutesLoggedForTimeClock({ todoTimeClock: todo.todoTimeClock, currentTime })
145-
: todo.minutesLogged;

0 commit comments

Comments
 (0)