I finally cracked and learned Typescript
Are those red squiggles and
any, any, void
ts
tsx
Fine, I give up, I’ll learn TypeScript.
What is it?
TypeScript is superset of features built on top of Javascript with the goal of static typechecking. It's open source and maintained by Microsoft.
Why?
Type errors (i.e. providing one type of value where another was expected) are the most common error in Javascript. These come from typos, misunderstanding API usage, incorrect runtime assumptions, or other errors—and Typescript will highlight them immediately.
Typescript does not run at runtime, it only helps at build time, sort of like a fancy linter.
Typescript has started to become a ubiquitous part of a frontend stack. TypeScript moves some kinds of common programming errors from runtime to compile time.
In Javascript, we only realize things fail at the exact moment the problematic use happens. With Typescript, we get better errors, at the invocation site.
How?
Luckily, existing Javascript code is also Typescript code. Typescript knows Javascript and will generate types automatically if none are defined.
// user.ts type User { name: string; id: number; } const user: User = { name: “Jimmy”, id: 1, }; function deleteUser(user: User) { //… }
Typescript types
Typescript extends primitive types (
boolean
bigint
null
number
string
symbol
undefined
- - allow any type
any
- - ensure type is declared by user
unknown
- - to prevent a type
never
- - a function that returns nothing or
void
undefined
- - for an array like
array
simply define as[1, 2, 3]
ornumber[]
Array<number>
It’s also possible to create custom complex types by combining simple ones.
- union - a type that can be thought of as “OR” for types
“It’s much more common to see unions types, I’d say in your day-to-day you’ll see 100:1 ratio—these are overwhelmingly the more common thing and it just happens to do with control flow” - Mike North, Developer Platform Lead at Stripe
type MyBool = true | false;
- intersection - “AND” for types: only the values that are shared by the intersecting sets
- generics - provide parameters to types for greater reusability
- structural type - a principle of Typescript that type checking focuses on the shape of the values, so even if a variable is never declared to be a certain type, if it contains the same shape as a declared type it will be assigned that type automatically
Annotations on variables
When declaring a variable using
const
var
let
let myName: string = “Jamison”;
But in most cases this is unnecessary because Typescript will infer the type automatically.
Functions
Parameter type annotations
function greet(name: string) { console.log(“Hello, ” + name.toUpperCase() + “!”); }
Return type annotations
Typescript will infer the function’s return type based on the
return
function getThirteen(): number { return 13; }
It’s beneficial to annotate return types because the type error will occur at the function level rather than where the return type is used in subsequent code.
Contextual typing
When a function appears in place where Typescript can determine how it’s going to be called, the parameters of that function are given types.
const name = [“Alice”, “Bob”, “Eve”]; //parameter s inferred to have type string names.forEach( function (s) { console.log(s.toUpperCase()) }
Void
For when we don’t care about the return value irrespective of the value being returned or not,
void
function invokeInFourSeconds(callback: () => undefined) { setTimeout(callback, 4000); } // ‘Disregard this return value’ function invokeInFiveSeconds(callback: () => void) { setTimeout(callback, 5000); } const values: number[] = []; //! Error: Type 'undefined' is not assignable to type 'number'. // push() returns the length of the array, a number invokeInFourSeconds(() => values.push(4)); invokeInFiveSeconds(() => values.push(4));
Callables
Describe call signature type
interface TwoNumberCalculation { // return type for an interface is ‘:’ (x: number, y: number): number; } // return type for a type is ‘=>’ type TwoNumberCalc = (x: number, y: number) => number; const add: TwoNumberCalculation = (a, b) => a + b; const subtract: TwoNumberCalc = (x, y) => x - y;
Constructables
interface DateConstructor { new (value: number): Date; } let MyDateConstructor: DateConstructor = Date; // const d: Date const d = new MyDateConstructor(1697923072611);
Function Overloads
By defining multiple function heads on the same implementation, it’s possible to ‘overload’ a function, thereby allowing the second argument’s type to change automatically based on the first argument.
type FormSubmitHandler = (data: FormData) => void; type MessageHandler = (evt: MessageEvent) => void; // these are two ‘heads’ of a single function declaration // links the first and second arguments function handleMainEvent( elem: HTMLFormElement, handler: FormSubmitHandler ): void; function handleMainEvent( elem: HTMLIFrameElement, handler: MessageHandler ): void; function handleMainEvent( elem: HTMLFormElement | HTMLIFrameElement, handler: FormSubmitHandler | MessageHandler ): void {} const myFrame = document.getElementsByTagName("iframe")[0]; // const myFrame: HTMLIFrameElement const myForm = document.getElementsByTagName("form")[0]; // const myForm: HTMLFormElement handleMainEvent(myFrame, (val) => {}); /* function handleMainEvent(elem: HTMLIFrameElement, handler: MessageHandler): any (+1 overload) */ handleMainEvent(myForm, (val) => {}); /* function handleMainEvent(elem: HTMLFormElement, handler: FormSubmitHandler): any (+1 overload) */
this
type
this
In order to type
this
this
this: This
This
function myClickHandler(this: HTMLButtonElement, event: Event) { this.disabled = true; } myClickHandler(new Event("click")); // lacks `this` const myButton = document.getElementsByTagName("button")[0]; const boundHandler = myClickHandler.bind(myButton); boundHandler(new Event("click")); // bound version: ok myClickHandler.call(myButton, new Event("click")); // also ok
Objects
It’s possible to annotate objects and their properties, separating them with either
,
;
function printCoordinates(pt: { x: number; y: number }) { console.log(pt.x, pt.y); }
To make a property optional, add a
?
function printName(obj: { first: string; last?: string }) { … }
Arrays
const fileExtensions = ["js", "ts"]; // typeof fileExtensions === string[]
Arrays can also be expressed like
Array<string>
const cars = [ { make: "Toyota", model: "Corolla", year: 2002, }, ];
Infers:
type cars = { make: string; model: string; year: number; }[];
Tuples
For data structures of non-arbitrary length, it’s possible to annotate each index of the tuple separately.
let myCar: [number, string, string] = [2002, "Toyota", "Corolla"];
Add
readonly
Type aliases
A type alias is a name for a type.
type Point = { x: number; y: number; }; function printCoordinates(pt: Point) { console.log(pt.x, pt.y); }
Type aliases function like add-ons for existing types.
SpecialDate
Date
getDescription()
type SpecialDate = Date & { getDescription(): string }; const newYearsEve: SpecialDate = Object.assign(new Date(), { getDescription: () => "Last day of the year", });
Typing dynamic object keys
For object indexes or keys that need types, there’s a
Record
//Allows dynamic keys to be added to cache at runtime const cache: Record\<string, string\> = {}
There’s also an index signature:
const cache: { [id: string]: string; } = {};
Which can be abstracted to its own interface:
type Cache { [id: string] : string; }
Try-Catch and Errors
In a generic try-catch statement the error destructures into an error name and message.
try { throw new TypeError(“oops”); } catch ({ name, message }) { console.log(name); // “TypeError” console.log(message); // “oops” }
For the scenarios where we’re not hardcoding the error type, it’s best practice to coerce or check the error with
instanceof
} catch (e) { // e: any at this point if (e instanceof Error) { return e.message; } }
Extending types
In order to keep code DRY, it’s possible to extend types with interfaces, or copy them to other named types, and add new members.
extends
interface Animal { id: string; name: string; } interface Mammel extends Animal { hairOrFurColor: string; }
Augment interfaces
window.document; // an existing property // ^? (property) document: Document window.exampleProperty = 42; // ^? (property) exampleProperty: number // tells TS that `exampleProperty` exists declare global { interface Window { exampleProperty: number; } }
Recursive types
type NestedNumbers = number | NestedNumbers[]; const val: NestedNumbers = [3, 4, [5, 6, [7], 59], 221];
Intersection types
Combine multiple types by extending them—everything that is in both sets:
interface Colorful { color: string; } interface Circle { radius: number; } type ColorfulCircle = Colorful & Circle;
Omit type
Constructs a type by picking all the properties from the
Type
Keys
interface User { id: string; firstName: string; lastName: string; } //Omit id from User type MyType = Omit\<User, "id"\>;
Narrowing with type guards
const success = [ "success", { name: "Mike North", email: "mike@example.com" }, ] as const; const fail = ["error", new Error("Something went wrong!")] as const; const [first, second] = outcome2; if (second instanceof Error) { // In this branch of your code, second is an Error // All properties of `second` are typed second; } else { // In this branch of your code, second is the user info second; }
Built-in type checks
let value: | Date | null | undefined | "pineapple" | [number] | { dateRange: [Date, Date] }; // instanceof if (value instanceof Date) { value; // ^Date } // typeof else if (typeof value === "string") { value; // ^pineapple } // Specific value check else if (value === null) { value; // ^null } // Truthy/falsy check else if (!value) { value; // ^undefined } // Some built-in functions else if (Array.isArray(value)) { value; // ^[number] } // Property presence check else if ("dateRange" in value) { value; // ^let value = { dateRange: [Date, Date] } } else { value; // ^never }
User-defined type guard
interface CarLike { make: string; model: string; year: number; } let maybeCar: any; // the guard function isCarLike(valueToTest: any): valueToTest is CarLike { return ( valueToTest && typeof valueToTest === "object" && "make" in valueToTest && typeof valueToTest["make"] === "string" && "model" in valueToTest && typeof valueToTest["model"] === "string" && "year" in valueToTest && typeof valueToTest["year"] === "number" ); } // using the guard if (isCarLike(maybeCar)) { maybeCar; // ^? let maybeCar: CarLike }
asserts value is Foo
asserts value is Foo
For use when throwing errors is optimal escape
function assertsIsCarLike(valueToTest: any): asserts valueToTest is CarLike { if ( !( valueToTest && typeof valueToTest === "object" && "make" in valueToTest && typeof valueToTest["make"] === "string" && "model" in valueToTest && typeof valueToTest["model"] === "string" && "year" in valueToTest && typeof valueToTest["year"] === "number" ) ) throw new Error(`Value does not appear to be a CarLike${valueToTest}`); } assertsIsCarLike(maybeCar); // maybeCar can now be assumed to be CarLike maybeCar;
Discriminated union
It’s possible to use signals to narrow a larger value
if (first === "error") { // In this branch of your code, second is an Error second; } else { // In this branch of your code, second is the user info second; }
Type Queries
Provides a way to get type representation of all property keys of a given interface
keyOf
keyOf
Object.keys()
type DatePropertyNames = keyof Date; // complete set of date properties that have string keys type DateStringPropertyNames = DatePropertyNames & string;
TypeOf
TypeOf
Get the type from a value.
async function main() { const apiResponse = await Promise.all([ fetch("https://example.com"), Promise.resolve("Titanium White"), ]); type ApiResponseType = typeof apiResponse; // type ApiResponseType = [Response, string] }
Index Access Type
interface Car { make: string; model: string; year: number; color: { red: string; green: string; blue: string; }; } let carColor: Car["color"]; //✔️ Reaching for something that exists let carSomething: Car["not-something-on-car"]; //! Reaching for something invalid let carColorRedComponent: Car["color"]["red"]; //✔️ Reaching for something nested // project a union through an index access type let carProperty: Car["color" | "year"]; // ✔️ Passing a union type through the index
Summary
Mike North provided an excellent exercise in his Typescript 5+ Fundamentals v4 about building more complex types using index signatures, recursive types, and union types.
// create a base JSON value type JSONPrimative = boolean | string | number | null; // use index signatures to restrict keys to strings and // values to any JSON type JSONObject = { [key: string]: JSONValue }; // arrays can be an array of any JSON value type JSONArray = JSONValue[]; // combine the set and wrap it with a recursive type type JSONValue = JSONPrimative | JSONObject | JSONArray; ////// TEST SUITE BELOW ////// function isJSON(arg: JSONValue) {} // POSITIVE test cases (must pass) isJSON("hello"); isJSON([4, 8, 15, 16, 23, 42]); isJSON({ greeting: "hello" }); isJSON(false); isJSON(true); isJSON(null); isJSON({ a: { b: [2, 3, "foo"] } }); // NEGATIVE test cases (must fail) // @ts-expect-error isJSON(() => ""); // @ts-expect-error isJSON(class {}); // @ts-expect-error isJSON(undefined); // @ts-expect-error isJSON(BigInt(143)); // @ts-expect-error isJSON(isJSON);