DEF-ensive Development with Typescript
Lets cut to the chase, Javascript as a language is beautiful hot garbage...
I just googled "hot garbage" and it just happenned to be javascript related...
Ok ok, put down your pitchforks! Es6 is a fantastic set of iterations over the langauge for building solutions for the web, server and mobile platforms. However its still got plenty of issues that crop up here and there. Dynamic typing helps create systems with minimal boilerplate and lets us ship faster.
Howver, as your application grows, javascript really invites you to paint yourself into a corner.
Take this innocent looing function.
function doTheThing(data) {
return data.subproperty.doTheThing();
}
While javascript will happily expect this, the actual behavior depends on an informal contract with any system that uses this function. If you needed to reafactor this, could you answer the following?
- What does the data argument look like?
We can see here that data needs to have a subpropert with a doTheThing() method. what does doTheThing()
actually do however? what does it return?
- What do we do with the return value?
How many systems rely on that returned value to follow a certain schema of properties and methods to fit the interface? in this case, we are delegating that to the data argument. This also adds a bit of indirection to the resulting system. If we rely on that return value to fit a certain interface, Then by definition, we need to look at all instances of data in the invocations to make sure they all obey as well.
- does subproperty always exist on data?
Say someone does some unrelated refactors on one verison of the data object and he removes subproperty. We'll get an error thrown... at runtime; Hopefully while you're developing. Runtime bugs have a way of scurrying away like cockoroachs and no one wants to deal with having to debug this at production. This is another thing you'd have to watch out for that can be difficult if you don't know the internal represntation of doTheThing(data)
I don't know about you but thats a lot to document and this is ONE FUNCTION.
This type of scrutiny required grows eponentially as the codebase gets larger. fortunatly, typescript gives us tools to automate that task by letting us add type annotations.
lets look at a typescript example.
interface IThingArgs {
subProperty: {
doTheThing: () => string;
};
}
function doTheThing(data: IThingArgs): number {
return data.subProperty.doTheThing();
}
Just looking at the interface and return type tells us all the information we need to inform how the data going into this function should work as well as what we can expect to get out of it.
Suppose we try to do something we shouldn't do.
let delegationThingy = {
subProperty: {
doTheThing() {
return 5
}
}
}
let result = doTheThing(delegationThingy);
console.log(result.length);
Our original javascript code will gladly accept this and run without error. Wether the return of a number rather than a string even shows up as an error is going to depend on how we use the result in subsequent code. In this case, we try to call length on it. Since numbers don't have a length property, we get a runtime error. This to me is terrifying as now we are on the mercy of the system to reveal an error that might be several stacks away from where the source of the issue is.
With our typescript code, we get a type contraint error informing us that delegationThingy.subProperty.doTheThing() returns a number and not a string as required by the IThingArgs. With the right tooling, A potentially costly to track down runtime error just turned into a error line that shows up as soon as you finish writing the offending line of code.
"Hold on a second," you might say, "What if we want generic reusable code? I can use the typeless version of doTheThing() in several contexts where I need different return types and delegate that between the argument and whatever is consuming the result!" And to this I would agree, the original javscript code is a very sharp knife and is more flexible than the typescript version.
We can have our cake and eat it to using generics.
interface IThingArgs<T> {
subProperty: {
doTheThing: () => T;
};
}
function doTheThing<T>(data: IThingArgs<T>): T {
return data.subProperty.doTheThing();
}
The <T>
indicates that we will be delegating the actual type that goes here to the consumer of this function. It is there that we can enforce the type constraint and typscript will ensure that everything works.
let delegationThingy = {
subProperty: {
doTheThing() {
return 5
}
}
}
let delegationThingy2 = {
subProperty: {
doTheThing() {
return "foo";
}
}
}
let result: number = doTheThing<number>(delegationThingy);
let result2: string = doTheThing<string>(delegationThingy2);
console.log(result + result2.length);
console.log(doTheThing<number>(delegationThingy2).length); //compile error
Recently, I've been pushing typescript in Redux projects. As Redux projects get bigger, I've personally struggled with going back to look at the source for my actions and reducers in order to know what arguments I need to plugin. Additonally, Much of my state tree follows an informally defined but very rigid structure that the rest of my app depends on.
I need some pretty solid guarantees that every reducer function preserves the structure of what my state needs to look like when its competed its modifications.
similar to our earlier example, we can use a set of interfaces to impose automated compile time error checking on our codebase.
export interface ITodo {
content: string;
completed: boolean;
subtasks?: ITodo[];
}
export interface ITodoListState {
tasks: ITodo[];
}
const initialState = {
tasks: [
{
content: 'first task',
completed: false,
}
]
}
When an interface has a ?
in a property, it means that that type is optional and the compile checker won't throw an error if it is not included. It does have an additional benefit in that it will make sure you account for values that could be undefined at compiletime.
Lets say we have a react component that renders a todo item. We might take a first crack at it like this.
interface ITodoItemProps {
todo: ITodo;
}
const TodoItem = (props: ITodoItemProps): JSX.Element => {
return (
<div>
<h1
className={(props.todo.completed) ? 'not-done' : 'done'}>
{props.todo.content}
</h1>
<ul>
{
props.todo.subtasks.map((subtask) => <li> {subtask.content} </li>)
}
</ul>
</div>
)
}
This can lead to nasty runtime errors because subtasks may not always exist. Typescript knows that this is an optional type and will bark at you to check for undefined first.
Again, The compiler is premtivly warning us about using a property that may not exist and informing us to include code to check for that. Adding a check for undefined removes the error and helps avoid an error in your react code.
const TodoItem = (props: ITodoItemProps): JSX.Element => {
return (
<div>
<h1
className={(props.todo.completed) ? 'not-done' : 'done'}>
{props.todo.content}
</h1>
{
(props.todo.subtasks) ? (
<ul>
{
props.todo.subtasks.map((subtask) => <li> {subtask.content} </li>)
}
</ul>
) : null
}
</div>
);
};
As you can see, Typescript gives us a lot of tools to avoid runtime issues with interfaces. We can even extend interfaces and pass extended types to functions expecting the base type similarly to extended classes.
In redux, we dispatch actions to the reducer. wouldn't it be nice to create a generic action type and enforce type constraints?
interface IAction<T> {
type: string;
payload: T
}
export const addTodoAction = (todo: ITodo): IAction<ITodo> => {
return {
type: "ADD_TODO",
payload: todo
}
};
const addTodoReducer = (state: ITodoListState, action: IAction<ITodo>): ITodoListState => {
return {
...state,
tasks: state.tasks.concat([action.payload])
};
}
Not only are we now able to verify arguments at compile time, Its fairly unambiguous what each piece of this system expects as input ad outputs. if either the input or the expected output does not match, typescipt will inform the user long before they ever see a runtime error manifest.