Runtime Typing

These slides are published.
Feel free to follow along.

devit.sdc.sx

I like a presentation that tells you exactly what it's going to be.

  • JavaScript/Limitations
  • TypeScript
  • Type Guards
  • io-ts
  • Further Reading

Getting to Know the Audience

Who is well-versed in ?

Who is well-versed in ?

Who knows what a Type Guard is?

Who could explain the difference between static and dynamic typing?

No, I'm not going to ask you to do it.

Who knows some type theory?

\[ \frac{\Gamma \vdash \textbf{e}_0 : (\tau_0 \rightarrow \tau_1) \qquad \Gamma \vdash \textbf{e}_1 : \tau_0}{\Gamma \vdash \textbf{e}_0(\textbf{e}_1) : \tau_1 } \]

JavaScript

“Sure, why not, add an array to a string.”

						
							// https://github.com/denysdovhan/wtfjs

							parseInt("Infinity", 19); // -> 18
							parseInt(0.000001);       // -> 0
							parseInt(0.0000001);      // -> 1
							parseInt(1 / 1999999);    // -> 5

							[1, 2, 3] + [4, 5, 6];    // -> '1,2,34,5,6'

							[] == 0;                  // -> true
							[undefined] == '';        // -> true
							[0] == '';                // -> false
						
					
							
								const netId = 'sdc2637';
								const netIdObj = await getUserObject(netId);

								const newEmplId = '3027689';
								netId['emplId'] = newEmplId;

								console.log(netId['emplId']); // prints "undefined"
							
						

One might argue that that's the fault of bad programming habits...

...but one also ought to consider Murphy's Law.

TypeScript

Are you SURE that number isn't undefined?

What is it?

  • JavaScript(-ish) syntax with static(-er) types!
  • Compiles to JavaScript
  • Adds rules to save you from the worst of yourself
							
								parseInt(0.000001);
								// Argument of type 'number' is not assignable to 
								// parameter of type 'string'.ts(2345)

								[1, 2, 3] + [4, 5, 6];
								// Operator '+' cannot be applied to types 'number[]' and 
								// 'number[]'.ts(2365)

								[] == 0;
								// This comparison appears to be unintentional because the 
								// types 'never[]' and 'number' have no overlap.ts(2367)
							
						

These are all still syntactically correct.

TypeScript checks more than syntax.

							
								const netId = 'sdc2637';
								const netIdObj = await getUserObject(netId);

								const newEmplId = '3027689';
								netId['emplId'] = newEmplId;
								// Element implicitly has an 'any' type because 
								// index expression is not of type 'number'.ts(7015)

								console.log(netId['emplId']);
							
						

It's not the most helpful error message in the world…

…but it's better than no error at all.

						
							type NetID = {
								netId: string
								emplId: string
								displayName: string
							}

							async function getUserObject(netId: string): Promise<NetID> {
								




							}

							const netId = 'sdc2637';                     // type: string
							const netIdObj = await getUserObject(netId); // type: NetID
						
					
						
							type NetID = {
								netId: string
								emplId: string
								displayName: string
							}

							async function getUserObject(netId: string): Promise<NetID> {
								return {
									netId: 'sdc2637',
									emplId: '3027689',
									displayName: 'Spencer Colton'
								};
							}

							const netId = 'sdc2637';                     // type: string
							const netIdObj = await getUserObject(netId); // type: NetID
						
					
						
							type NetID = {
								netId: string
								emplId: string
								displayName: string
							}

							async function getUserObject(netId: string): Promise<NetID> {
								const userResponse = await fetch(`https://myawesomenetidapi.edu/${netId}`);
								return await userResponse.json();



							}

							const netId = 'sdc2637';                     // type: string
							const netIdObj = await getUserObject(netId); // type: NetID
						
					
						
							type NetID = {
								netId: string
								emplId: string
								displayName: string
							}

							async function getUserObject(netId: string): Promise<NetID> {
								const userResponse = await fetch(`https://myawesomenetidapi.edu/${netId}`);
								return await userResponse.json();
								// This works, but should it?
								// All of this is checked at compile-time, and the compiler cannot
								// make a guarantee about what this API returns!
							}

							const netId = 'sdc2637';                     // type: string
							const netIdObj = await getUserObject(netId); // type: NetID
						
					

Type Guards

JavaScript provides some basic runtime type checking that TypeScript can make use of.

						
							function getTheLengthOfAThing(aThing: unknown): number {
								return aThing.length;
							}

							// 
						
					
						
							function getTheLengthOfAThing(aThing: unknown): number {
								return aThing.length;
							}

							// 'aString' is of type 'unknown'.ts(18046)
						
					
						
							function getTheLengthOfAThing(aThing: unknown): number {
								if (typeof(aThing) !== 'string')
									throw new Error('I only know string things');

								return aThing.length;
							}

							// Works!
						
					

Fabulous! Certainly it's always concise and readable, right?

							
								// For information only
								type ThingWithAName = {
									name: string
								}

								function getTheNameOfTheUnknownThing(aThing: unknown): string {
									if (typeof(aThing) !== 'object' || aThing === null)
										throw new Error('Only objects have names');

									if (!('name' in aThing))
										throw new Error('Thing has no name');

									const name = aThing.name;
									if (typeof(name) !== 'string')
										throw new Error('Name is not a string');

									return name;
								}
							
						
							
								// For information only
								type ThingWithAName = {
									name: string
								}

								function getTheNameOfTheUnknownThing(aThing: unknown): string {
									if (typeof(aThing) !== 'object' || aThing === null)
										throw new Error('Only objects have names');

									if (!('name' in aThing))
										throw new Error('Thing has no name');

									const name = aThing.name;
									if (typeof(name) !== 'string')
										throw new Error('Name is not a string');

									return name;
								}
							
						
							
								// For information only
								type ThingWithAName = {
									name: string
								}

								function getTheNameOfTheUnknownThing(aThing: unknown): string {
									if (typeof(aThing) !== 'object' || aThing === null)
										throw new Error('Only objects have names');

									if (!('name' in aThing))
										throw new Error('Thing has no name');

									const name = aThing.name;
									if (typeof(name) !== 'string')
										throw new Error('Name is not a string');

									return name;
								}
							
						
							
								// For information only
								type ThingWithAName = {
									name: string
								}

								function getTheNameOfTheUnknownThing(aThing: unknown): string {
									if (typeof(aThing) !== 'object' || aThing === null)
										throw new Error('Only objects have names');

									if (!('name' in aThing))
										throw new Error('Thing has no name');

									const name = aThing.name;
									if (typeof(name) !== 'string')
										throw new Error('Name is not a string');

									return name;
								}
							
						
							
								// For information only
								type ThingWithAName = {
									name: string
								}

								function getTheNameOfTheUnknownThing(aThing: unknown): string {
									if (typeof(aThing) !== 'object' || aThing === null)
										throw new Error('Only objects have names');

									if (!('name' in aThing))
										throw new Error('Thing has no name');

									const name = aThing.name;
									if (typeof(name) !== 'string')
										throw new Error('Name is not a string');

									return name;
								}
							
						

But it gets worse...

						
							type ThingWithAName {
								name: string
							}

							function getTheNameOfTheThing(aThing: ThingWithAName): string {
								return aThing.name;
							}

							const thing: unknown = { name: 'Unknown Thing' };
							if (typeof(thing) !== 'object' || thing === null || 
							    !('name' in thing) || typeof(thing.name) !== 'string')
								throw new Error('Thing does not have a name');

							console.log(getTheNameOfTheThing(thing));

							//
						
					
						
							type ThingWithAName {
								name: string
							}

							function getTheNameOfTheThing(aThing: ThingWithAName): string {
								return aThing.name;
							}

							const thing: unknown = { name: 'Unknown Thing' };
							if (typeof(thing) !== 'object' || thing === null || 
							    !('name' in thing) || typeof(thing.name) !== 'string')
								throw new Error('Thing does not have a name');

							console.log(getTheNameOfTheThing(thing));

							// Type 'unknown' is not assignable to type 'string'.ts(2345)
						
					

This is a deliberate and fundamental limitation of the language.

https://github.com/microsoft/TypeScript/issues/31755#issuecomment-498669080

What to do now?

  • “I know more than you”
  • “I know more than you, here's some proof”
  •  (out of scope)

“I know more than you”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Unknown Thing' };
								console.log(getTheNameOfTheThing(thing as ThingWithAName));
							
						

“I know more than you”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Unknown Thing' };
								console.log(getTheNameOfTheThing(thing as ThingWithAName));
							
						

Generally considered bad form.

You've given up a lot that was useful about the compiler.

Bugs that was built to prevent now come back.

“...here's some proof”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								function thingHasAName(aThing: unknown): aThing is ThingWithAName {
									return typeof(aThing) === 'object' && aThing !== null && 
									       'name' in aThing && typeof(aThing.name) === 'string';
								}

								const thing: unknown = { name: 'Unknown Thing' };
								if (!thingHasAName(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing));
							
						

“...here's some proof”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								function thingHasAName(aThing: unknown): aThing is ThingWithAName {
									return typeof(aThing) === 'object' && aThing !== null && 
									       'name' in aThing && typeof(aThing.name) === 'string';
								}

								const thing: unknown = { name: 'Unknown Thing' };
								if (!thingHasAName(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing));
							
						

“...here's some proof”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								function thingHasAName(aThing: unknown): aThing is ThingWithAName {
									return typeof(aThing) === 'object' && aThing !== null && 
									       'name' in aThing && typeof(aThing.name) === 'string';
								}

								const thing: unknown = { name: 'Unknown Thing' };
								if (!thingHasAName(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing));
							
						

Cool, but can I lie?

Of course.

“...here's some LIES”

							
								type ThingWithAName {
									name: string
								}

								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								function thingHasAName(aThing: unknown): aThing is ThingWithAName {
									return true;
								}

								const thing: unknown = 'this string has no name';
								if (!thingHasAName(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // prints "undefined"
							
						

“Complex” Types

							
								type NetID = {
									netId: string,
									active: boolean,
									hrEmplId: string,
									sesEmplId: string,
									displayName: string,
									firstName: string,
									middleName: string,
									lastName: string,
									pwdLastChangeDate: string,
									nuAllSchoolAffiliations: string[]
									...
								}
							
						
							
								function isNetID(thing: unknown): thing is NetID {
									if (typeof(thing) !== 'object' || thing === null)
										return false;

									if (!('netId' in thing && 'active' in thing && 'hrEmplId' in thing &&
										    'sesEmplId' in thing && 'displayName' in thing && 'firstName' in thing &&
												'middleName' in thing && 'lastName' in thing && 'pwdLastChangeDate' in thing &&
												'nuAllSchoolAffiliations' in thing))
										return false;

									if (typeof(thing.netId) !== 'string' || typeof(thing.active) !== 'boolean' ||
									    typeof(thing.hrEmplId !== 'string') || typeof(thing.sesEmplId) !== 'string' || 
											typeof(thing.displayName) !== 'string' || typeof(thing.firstName) !== 'string' || 
											typeof(thing.middleName) !== 'string' || typeof(thing.lastName) !== 'string' ||
											typeof(thing.pwdLastChangeDate) !== 'string' || typeof(thing.nuAllSchoolAffiliations !== 'string'))
										return false;

									if (!Array.isArray(thing.nuAllSchoolAffiliations))
										return false;

									return true;
								}
							
						

Shouldn't there be a library for this?

There's a library for this.

github.com/gcanti/io-ts

io-ts exploits the patterns in writing these type guards.

If you know how to tell if something is a string, you can reuse that code when checking if a property of an object is a string.

And so on, and so forth...

							
								import * as t from 'io-ts';								
								const ThingWithAName = t.type({
									name: t.string
								});

								type ThingWithAName = t.typeof<typeof ThingWithAName>
								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Name' };
								if (!ThingWithAName.is(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // Works!
							
						
							
								import * as t from 'io-ts';								
								const ThingWithAName = t.type({
									name: t.string
								});

								type ThingWithAName = t.TypeOf<typeof ThingWithAName>
								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Name' };
								if (!ThingWithAName.is(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // Works!
							
						
							
								import * as t from 'io-ts';								
								const ThingWithAName = t.type({
									name: t.string
								});

								type ThingWithAName = t.TypeOf<typeof ThingWithAName>
								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Name' };
								if (!ThingWithAName.is(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // Works!
							
						
							
								import * as t from 'io-ts';								
								const ThingWithAName = t.type({
									name: t.string
								});

								type ThingWithAName = t.TypeOf<typeof ThingWithAName>
								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Name' };
								if (!ThingWithAName.is(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // Works!
							
						
							
								import * as t from 'io-ts';								
								const ThingWithAName = t.type({
									name: t.string
								});

								type ThingWithAName = t.TypeOf<typeof ThingWithAName>
								function getTheNameOfTheThing(aThing: ThingWithAName): string {
									return aThing.name;
								}

								const thing: unknown = { name: 'Name' };
								if (!ThingWithAName.is(thing))
									throw new Error('Thing does not have a name');

								console.log(getTheNameOfTheThing(thing)); // Works!
							
						

Limitations

External Types are Tricky

It's easiest to do io-ts if you are the one writing the types.

You can, in theory, write code that will allow io-ts to be a type guard for a type in code you didn't write or don't control...

...but it's more annoying, and if the upstream code changes that type, you have to rewrite your code as well or risk the same issues that you would get with plain old JavaScript.

Complex Types are Weird

							
								type NetID = {
									netId: string
									hrEmplId?: string // hrEmplId: string | undefined
									sesEmplId?: string
								}

								const NetID = t.intersection([
									t.type({
										netId: t.string
									}),
									t.partial({
										hrEmplId: t.string,
										sesEmplId: t.string
									})
								]);
							
						

Error Reporting is Weird

							
								import { PathReporter } from 'io-ts/PathReporter';
								if (!NetID.is(thing)) {
									console.error('Invalid NetID format');
									console.error(PathReporter.report(NetID.decode(thing)));
								}
							
						

Further Reading

Questions?

Thank you!