Things I wish I knew before jumping into TypeScript
Last updated on 2024–01–01 to include changes up to TS 5.3.2
What is TypeScript
TypeScript is a programming language created by Microsoft in 2010. On October, 1st, 2012 TypeScript was publicly unveiled. Later on, in November 2014 the Angular team at Google joined the effort.
TypeScript started from a problem that was observed at Microsoft which was to scale applications written in JavaScript.
TypeScript has two faces :
- Static type checker for JavaScript
- Transpiler taking TS as input and spitting out JS. The transpiler supports even features not yet finalized in ECMAScript (e.g. decorators)
Benefit of TypeScript
- Self-documented code
- Catch bug while typing in the editor, before the program runs and even before compilation
- Better tooling (intellisense)
- Gradual migration (JavaScript is TypeScript)
Source code is the best documentation.
Compilers
There are many options for compiling TypeScript down to JavaScript, just to name a few.
- TypeScript compiler called
tsc
, itself written in TypeScript. Used by ts-node. - Babel plugin called
@babel/preset-typescript
. Used by CRA. - swc, a TypeScript compiler written in Rust. Used by Next.js and Parcel.
- esbuild, a TypeScript compiler written in Go. Used by Vite and Remix.
- Rome written in Rust, a vertical integrator of JavaScript’s tooling, by the Babel’s creator.
- bun, a JavaScript runtime, using the JavaScriptCore engine, written in Zig.
TSC Bottleneck
As we have discussed before, TypeScript embraces a type checker as well as a transpiler. Remember, the TypeScript compiler — tsc
— is written in TS. Executing tsc
performs type-checking and transpiling. Using tsc
the build time grows as project grows. Deno called this the TSC Bottleneck.
Recently some projects — see right above — written in more performant languages for transpiling TypeScript code, have emerged. The caveat is on the type-checker side. This is not something easy to re-implement, in contrast to the transpiler. One strategy is to use the TypeScript compiler for type-checking purpose only, by setting the flag noEmit
to true
. Another way is to set emitDeclarationOnly
to true
together with declaration
to true
.
create-react-app adopts this strategy, letting Babel doing the transpiling. Since Babel does not perform any type-checking, Webpack hands over the type-checking job to tsc
through a dedicated plugin. Parcel, Deno and Vite, have a similar approach by using a dedicated transpiler (swc, esbuild) and skipping type-checking for dev build.
TypeScript = JavaScript + types + tools
TypeScript comes along with a lot of tools, as for example intellisense (autocompletion, autoimport, refactoring, jump-to-definition).
Even if your are writing JavaScript, the TypeScript language service provides tooling to the IDE.
TypeScript provides very few features which are not type related. These are exceptions to the goal of the TypeScript team, which is to focus on type-safety only.
Enums are one of the few features TypeScript has which is not a type-level extension of JavaScript.
JavaScript is TypeScript
TypeScript is a superset of JavaScript. Hence, JavaScript is TypeScript.
JavaScript and TypeScript are close cousins.
What are types ?
Types are sets. Let’s illustrate this.
let first: number = 0; // ✅ since 0 is part of the set `number`
first = 1; // ✅ since 1 is part of the set `number`
let index: unknown = 0; // ✅ since 0 is part of the set `unknown`
index = "1"; // ✅ since "1" is part of the set `unknown`
let uuid: string = "e58ed763-928c-4155-bee9-fdbaaadc15f3"; // ✅
uuid = 38571; // 💥 Type 'number' is not assignable to type 'string'.(2322)
Type ‘🍎‘ is not assignable to type ‘🍐‘.(2322)
This type of TS compilor error is your bread-and-butter while working with TypeScript.
🍎 is not a assignable to 🍐. Another way of saying that in TS terms is, 🍎 does not extends 🍐.
From a set theory perspective, this means that 🍎 is not a subset of 🍐.
// 💡 A `extends` B is equivalent to A `is assignable to` B.
// 💡 In set theory terms: `is a subset of`.
type A = 38571 extends string ? true : false // false
🧬 Data vs 🏷 Type
A good mental model in TypeScript is to keep in mind:
Data
vsType
This two things are very separated worlds. But there are some ways to connect them to each other. Indeed, you can generate type based on data. But this does not work the other way.
If you get the following error from the TypeScript compiler, this means that you might confuse data and type.
refers to a value, but is being used as a type here. (2749)
🧬 Data — From data to type: typeof
const direction = {
Top: 0,
Right: 1,
Bottom: 2,
Left: 3,
};
type Direction = typeof direction;
/*
{
Top: number,
Right: number,
Bottom: number,
Left: number,
}
*/
🧬 Data — from immutable array to union: [number]
// 💡 Type can be generated from immutable (readonly) array only
const days = [0, 1, 2, 3, 4, 5, 6] as const;
type Days = typeof days[number]; // 👍 0 | 1 | 2 | 3 | 4 | 5 | 6;
////////////////////////////////////////////////////////////////////
// 💡 Indeed, arrays can change at runtime
const days = [0, 1, 2, 3, 4, 5, 6];
type Days = typeof days[number]; // 👎🏻 number
🏷Type —from type to union of values: T[keyof]
interface User {
email: string,
isLoggedIn: boolean,
id: number,
}
type Values = User[keyof User]; // string | number | boolean
🏷 Type —from type to union of keys: keyof
const get = <D,>(data: D, key: keyof D) => data[key];
const kyoko = { first: 'Kyoko', salary: 81200 };
get(kyoko, 'salary'); // ✅ 81200
get(kyoko, 'address'); // 💥 `address` is not part of 'first' | 'salary'
Enum: not type-safe for number values
// 😱 DON'T do that, not type-safe, except as of TS 5.0
enum LogLevel {
error = 0,
warning = 1,
info = 2,
log = 3,
}
const log = (logLevel: LogLevel, ...message: any[]) => {
switch (logLevel) {
case LogLevel.error:
return console.error(...message);
case LogLevel.warning:
return console.warn(...message);
case LogLevel.info:
return console.info(...message);
default:
return console.log(...message);
}
};
log(2, "info logging"); // ✅
log(3, "log logging"); // ✅
log(3240, "invalid logging"); // ✅ 😱, TS 5.0 fixes that
go for immutable object instead, which is type-safe :
// 💡 in JS `const` is not immutable, unlike TS `const` assertion (TS 3.4)
const logLevel = {
error : 0,
warning : 1,
info : 2,
log : 3,
} as const;
const log = (level: typeof logLevel[keyof typeof logLevel], ...message: any[]) => {
switch (level) {
case logLevel.error :
return console.error(...message);
case logLevel.warning:
return console.warn(...message);
case logLevel.info:
return console.info(...message);
case logLevel.log:
return console.log(...message);
default: // 💡 exhaustiveness checking
const _exhaustiveCheck: never = level;
return _exhaustiveCheck;
}
};
log(2, "info logging"); // ✅
log(3, "log logging"); // ✅
log(3240, "invalid logging"); // 💥 Argument of type '3240' is not assignable to parameter of type '0 | 1 | 2 | 3'.(2345)
Enum is reversible: Reverse mapping
// 💡 reverse mapping is possible only when values of enum are number
enum Weekdays {
Sunday = 0,
Monday = 1,
Tuesday = 2,
Wednesday = 3,
Thursday = 4,
Friday = 5,
Saturday = 6
}
const date = new Date('2022-01-10'); // Monday, 10th January 2022
const day = Weekdays[date.getDay()]; // Monday
Object function
import { ReactElement } from "react";
interface FunctionComponent<P> {
(props: P): ReactElement | null,
displayName?: string,
}
interface Props {
url: string,
label: string,
}
const Link: FunctionComponent<Props> = ({ url, label }) =>
<a href={url}>{label}</a>;
Link.displayName = 'Link';
export default Link;
// 💡 technique used in @types/react
Generic type utility: Like a function for type
type ValueOf<T> = T[keyof T]; // Utility type
const Second = {
day: 86400,
hour: 3600,
minute: 60,
unit: 1,
} as const;
type SecondValues = ValueOf<typeof Second>; // 86400 | 3600 | 60 | 1
Type narrowing
Type narrowing is the process of refining a type at a specific part of the code to ensure type-safety. There are different ways to do type narrowing.
Type narrowing can be archived using type guard, const assertion, variable declaration (let
vs const
), satisfies
, const type parameter.
The Control Flow Analysis (CFA) — a TypeScript’s compiler feature— does infer type at a specific location of the code by analyzing type guards and assignments. This way, CFA can infer the return type of functions.
const getAge = (birthDate: string | Date) => {
if(typeof birthDate === "string") { // 💡 here is a type guard
return new Date().getFullYear() - new Date(birthDate).getFullYear();
}
// CFA knows that, from here on, `birthDate` is of type `Date`
return new Date().getFullYear() - birthDate.getFullYear();
}
////////////////////////////////////////////////////////////////////
// equivalent code
const getAge = (birthDate: string | Date) =>
birthDate instanceof Date ? // 💡 here is a type guard
new Date().getFullYear() - birthDate.getFullYear() :
// CFA knows that, from here on, `birthDate` is of type `string`
new Date().getFullYear() - new Date(birthDate).getFullYear();
Type inference: Let TS compiler do its job
const age: number = 42; // 👎🏻
const age = 42; // 👍
////////////////////////////////////////////////////////////////////
const isEligible = (age: number): boolean => age >= 18; // 👎🏻
const isEligible = (age: number) => age >= 18; // 👍
////////////////////////////////////////////////////////////////////
function wrapInArray<T>(input: T){
return [input];
}
wrapInArray<number>(1); // 👎🏻 [1]
wrapInArray(3); // 👍 [3]
never type : ∅ — empty set — (TS 2.0)
type emptySet = string & number; // never
// 💡 use cases: filtering and exhaustiveness checking (see examples)
Discriminated unions: algebraic data types (TS 2.0)
import { FunctionComponent, useState, ChangeEvent } from "react";
// 💡 `kind` key is the discriminant
type Base = { kind: string; label: string };
type TextField = Base & { kind: "text"; placeholder?: string };
type Checkbox = Base & { kind: "checkbox" };
const Input: FunctionComponent<TextField | Checkbox> = (props) => {
const [val, setVal] = useState(props.kind === "checkbox" ? false : "");
const onChange = ({ target }: ChangeEvent<HTMLInputElement>) =>
setVal(props.kind === "checkbox" ? target.checked : target.value);
// 💡 Control Flow Analysis - CFA - can infer type in each branch
switch (props.kind) {
case "text": // 💡 type guard
return (
<label>
{props.label}
<input
type="text"
placeholder={props.placeholder}
onChange={onChange}
value={val as string} />
</label>
);
case "checkbox": // 💡 type guard
return (
<label>
{props.label}
<input
type="checkbox"
onChange={onChange}
checked={val as boolean} />
</label>
);
default: // 💡 exhaustiveness checking
const _exhaustiveCheck: never = props;
return _exhaustiveCheck;
}
};
export default Input;
Lookup type: aka indexed access type
interface Person {
first: string,
address: {
street: {
name: string,
no: string,
},
city: {
name: string,
zip: string,
}
},
children: Array<Person>, // 💡 Recursive type
}
type Address = Person['address'];
/*
{
street: {
name: string;
no: string;
},
city: {
name: string,
zip: string,
}
}
*/
type Street = Person['address']['street'];
/*
{
name: string;
no: string;
}
*/
type Child = Person['children'][number]; // Person
Array vs tuple
// 💡 Array<number> and number[] are same. But [number] is a tuple.
type Grades = Array<number>;
const ryokoGrades: Grades = [2, 4, 5, 2]; // ✅
const reiGrades: Grades = [4, 4, 5]; // ✅
// 💡 A tuple has a fixed size
type RGB = [number, number, number];
let yellowLemon: RGB = [255, 244, 79];
yellowLemon = [255, 244, 109, 112]; // 💥
// 💡 fixed size does not mean immutable
let blueNavy: RGB = [11, 11, 69];
blueNavy[0] = 12; // ✅
// 💡 immutable tuple
const bloodOrange: Readonly<RGB> = [188, 56, 35];
bloodOrange[0] = 187; // 💥
any vs unknown
// 💡 whatever value can be assigned to to `any` or `unknown` (TS 3.0)
const getYear = (date: any) => date.getFullYear();
const year0 = getYear(new Date("2023-01-23")); // ✅
const year1 = getYear(1674459749659); // 🐛 at runtime
///////////////////////////////////////////////////////////////////////////
const getYearDont = (date: unknown) => date.getFullYear(); // 💥
const getYearOK = (date: unknown) =>
(date instanceof Date) ? date.getFullYear() : undefined // ✅ narrowing
const year2 = getYearOK(new Date("2023-01-23")); // ✅ 2023
const year3 = getYearOK(1674459749659); // ✅ undefined
satisfies: narrow to literal upon assignment (TS 4.9)
type Color = [number, number, number] | string;
type Theme = {
primary: Color;
secondary: Color;
error: Color;
warning: Color;
}
const myTheme0: Theme = {
primary: [63, 134, 181],
secondary: [125, 59, 140],
error: "#D60D0D",
warning: "#F0BC22",
}
const errorColor0 = myTheme0.error.toLowerCase();
// 💥 'toLowerCase' does not exist on type '[number,number,number]'.(2339)
////////////////////////////////////////////////////////////////////
const myTheme1 = {
primary: [63, 134, 181],
secondary: [125, 59, 140],
error: "#D60D0D",
warning: "#F0BC22",
} satisfies Theme;
const errorColor1 = myTheme1.error.toLowerCase(); // ✅ "#d60d0d"
Another use case is to avoid type duplication, solved before the introduction of satisfied
, by the identity function.
// 💡 Before TS 4.9
import { FC } from "react";
import cart from "./cart.svg";
import cloud from "./cloud.svg";
import map from "./map.svg";
const id = <D extends object>(o: D) => o;
// `id` prevents duplication: ICONS: Record<'cart' |'cloud' |'map', string>
const ICONS = id({
cart,
cloud,
map,
});
interface Props {
name: keyof typeof ICONS;
}
const Icon: FC<Props> = ({name}) => <img src={ICONS[name]} alt={name} />;
export default Icon;
// use the component
<Icon name="cloud" /> ✅
<Icon name="coud" /> 💥
////////////////////////////////////////////////////////////////////
// 💡 From TS 4.9
import { FC } from "react";
import cart from "./cart.svg";
import cloud from "./cloud.svg";
import map from "./map.svg";
const ICONS = {
cart,
cloud,
map,
} satisfies Record<string, string>;
interface Props {
name: keyof typeof ICONS;
}
const Icon: FC<Props> = ({name}) => <img src={ICONS[name]} alt={name} />;
export default Icon;
// use the component
<Icon name="cloud" /> ✅
<Icon name="coud" /> 💥
extends in Generics: constraint
type Person = {
first: string,
last: string,
}
////////////////////////////////////////////////////////////////////
const fullName = (p: Person) => `${p.first} ${p.last}`;
const akiko = fullName({ first: 'Akiko', last: 'Matsuo' }); // ✅
const kei = fullName({ first: 'Kei', last: 'Sato', id: 'A9' }); // 💥
////////////////////////////////////////////////////////////////////
// 👍 with a constraint, check the minimal fields, scale better
const fullName = <T extends Person>(p: T) => `${p.first} ${p.last}`;
const akiko = fullName({ first: 'Akiko', last: 'Matsuo' }); // ✅
const kei = fullName({ first: 'Kei', last: 'Sato', id: 'A9' }); // ✅
// 💡 this is due to the structural typing nature of TypeScript
Function overloading
class Moment {
value: Date;
constructor(date: string | Date | number){
this.value = new Date(date);
}
hour(): number
hour(newHour: number): this
hour(newHour?: number){
if(newHour === undefined){
return this.value.getHours();
}
else {
this.value.setHours(newHour);
return this;
}
}
}
const hour = new Moment('2021-12-18T15:23:42+01:00')
.hour(21)
.hour();
// hour === 21
// 💡 technique used in Moment.js
Template literal type (TS 4.1)
type Period = 'year' | 'month' | 'week' | 'date';
type PeriodPlural = `${Period}s`;
// 'years' | 'months' | 'weeks' | 'dates'
Loop: Mapped type (TS 4.1)
// 💡 key remapping with `as` introduced by TS 4.1
type Period = 'year' | 'month' | 'week' | 'date';
type PeriodKeys = {
[Key in Period as `${Key}s`]: never;
};
type PeriodPlural = keyof PeriodKeys;
// 'years' | 'months' | 'weeks' | 'dates';
Index signature vs Record: They are equivalent
type Period = 'year' | 'month' | 'day';
////////////////////////////////////////////////////////////////////
// 💡 index signature
type KeysPlural<T extends string> = {
[Key in T as `${Key}s`]: number;
};
const duration: KeysPlural<Period> = {
years: 4,
months: 21,
days: 44
}; // ✅
////////////////////////////////////////////////////////////////////
// 💡 Record (TS 2.1)
type KeysPlural<T extends string> = Record<`${T}s`, number>;
const duration: KeysPlural<Period> = {
years: 4,
months: 21,
days: 44
}; // ✅
infer: capture type (pattern matching)
// 💡 conditional type and `infer` keyword were introduced by TS 2.8
type Tail<A> = A extends [infer H, ...infer T] ? T : never;
type T0 = Tail<[4]>; // []
type T1 = Tail<[4, 13]>; // [13]
type T2 = Tail<[]>; // never
// 💡 better: with a type constraint, prevent the generic to be misused
type Tail<A extends readonly unknown[]> = A extends [infer H, ...infer T] ?
T :
never;
Conditional type is distributive
type Flatten<T> = T extends Array<infer R> ? R : T;
type ValueOf<T> = T[keyof T];
type Store = {
cars: Array<{
brand: string,
model: string,
horsepower: number}>,
cycles: Array<{
brand: string,
size: string,
numberOfSpeeds: number}>
};
type Products = Flatten<ValueOf<Store>>;
/*
{
brand: string;
model: string;
horsepower: number;
}
|
{
brand: string;
size: string;
numberOfSpeeds: number;
}
*/
// 💡 under the hood the following happens, distributivity
// Flatten<Array<{.. model .. }> | Array<{.. size .. }>>
// Flatten<Array<{.. model .. }>> | Flatten<Array<{.. size .. }>>
Conditional type: Recursion
// 💡 TS 4.5 supports tail-recursion, see the param `Acc`
type Span<N extends number, Acc extends number[] = []> =
Acc['length'] extends N
? Acc
: Span<N, [...Acc, Acc['length']]>;
// 💡 [number] to convert array type to union type
type Range256 = Span<256>[number];
type RGB = [Range256, Range256, Range256];
////////////////////////////////////////////////////////////////////
// 💡 the equivalent version with data
function span(upper, acc=[]) {
return upper === 0
? acc
: span(upper-1, [ upper-1, ...acc ]);
}
span(10); // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const Type Parameters : narrow to literal in args
// 💡 Before TS 5.0
const get = <D, K extends keyof D>(data: D, key: K) => data[key];
const wage0 = get({ first: 'Liam', wage: 8200 }, 'wage'); // `number`
const wage1 = get({ first: 'Leo', wage: 250 } as const, 'wage'); // `250`
// 💡 As of TS 5.0
const getTS5 = <const D, K extends keyof D>(data: D, key: K) => data[key];
const wage2 = getTS5({ first: 'Uwe', wage: 1630 }, 'wage'); // `1630`
Wrap up: union, extends, conditional, never, distributivity, generics
type Food = "🍊" | "🍌" | "🥒" | "🍐" | "🍓" | "🥕" | "🍒" | "🍆";
// 💡 `never` is useful for filtering
type FilterOut<T, U> = T extends U ? never : T;
type Fruits = FilterOut<Food, "🥒" | "🥕" | "🍆">;
// "🍊" | "🍌" | "🍐" | "🍓" | "🍒"
// 💡 how the distributivity happens
/* FilterOut<"🍊", "🥒" | "🥕" | "🍆"> |
FilterOut<"🍌", "🥒" | "🥕" | "🍆"> |
FilterOut<"🥒", "🥒" | "🥕" | "🍆"> |
FilterOut<"🍐", "🥒" | "🥕" | "🍆"> |
FilterOut<"🍓", "🥒" | "🥕" | "🍆"> |
FilterOut<"🥕", "🥒" | "🥕" | "🍆"> |
FilterOut<"🍒", "🥒" | "🥕" | "🍆"> |
FilterOut<"🍆", "🥒" | "🥕" | "🍆">;
*/
Takeaway
- As of TS 0.9 TS supports Generics which is likely the most import concept to get. Think of Generics as a type parameter to function.
- type
any
is considered as a bad practice (turn-off type-checking). Avoid usingenum
(not type-safe and generates a lot of code). Don’t use the typesObject
,Function
,Number
,Symbol
,Boolean
,String
. Do not use the!
operator (non-null assertion operator). - TS provides a way to create types programmatically. This is the so-called type-level programming. TS type system turns to be Turing complete.
- The adoption of TypeScript is massive in tech. Some companies have seen some productivity growth and code quality improvements after migrating.
- TS compiler’s feature called Control Flow Analysis (CFA) can infer type at specific location of the code. When CFA cannot do type inference, then do type assertion, with
as
. any
andunknown
both embrace all other types — aka top type. In other words, they are supersets of all other types. Unlikeany
,unknown
provides type-safety by enforcing developers to do type narrowing.- TypeScript provides very few not type related features which do not exist in JavaScript: enum, namespace, parameter properties.
TS is made of two separated worlds,🧬 data vs 🏷 type. The developer’s role is to make these two worlds gather together, at least for the development time.
References
Generic type React component in TypeScript
Mutually exclusive props in TypeScript
How to iterate over types in TS ?
What does the keyword extends mean in TypeScript ?
How do sets and types relate in TypeScript ?
Zhenghao’s site, Zhenghao He
zackoverflow.dev, Zack
TypeScript Evolution, Marius Schulz