Skip to content

Trial by TypeScript

Seattle React Meetup

TypeScript gets easier to understand when you stop treating it as a different language and start treating it as a way of taking notes about the JavaScript you were already writing.

That is the emotional shift. The syntax matters, but the syntax is not the point. The point is making intent visible. TypeScript lets the code say what it expects, what it returns, what shape an object should have, and which states are allowed. It turns implicit assumptions into explicit contracts.

That can feel verbose at first. Strongly typed languages force you to make behavior explicit, and explicit behavior takes up room. But the extra room buys you readability, editor feedback, safer refactors, and fewer mysteries when you revisit the code later.

Why TypeScript Caught On

By 2019, TypeScript was no longer a niche bet. Storybook, Yarn, MobX State Tree, Apollo, Next.js, Prisma, Jest, Framer Motion, Ember, Cycle, RxJS, and Visual Studio Code were all either written in TypeScript, migrating toward it, or treating it as a first-class part of the ecosystem.

That adoption mattered because it changed the social cost. TypeScript stopped being the unusual choice and became a reasonable default for serious JavaScript projects. It also made the learning curve more valuable: the more libraries shipped types, the more your editor could help you understand the code you were using.

For me, the bigger effect was not just fewer bugs. Learning TypeScript changed how I wanted to write JavaScript. Static analysis became a practical stop gap between no tests and good tests. It made me rethink whether a function, component, or state transition was actually clear enough to share.

A Small Example

Start with a plain JavaScript function:

function getCurrency(amount) {
	return amount.toLocaleString("en", {
		style: "currency",
		currency: "USD",
	});
}

getCurrency(5000);

This works if amount is a number. But the function does not say that. It accepts anything until runtime proves otherwise.

getCurrency("5000");
getCurrency();
getCurrency([5000, 4000]);

Some of those calls fail. Some behave strangely. None of the mistakes are obvious at the call site unless you already know the implementation.

With TypeScript, the function can write down the contract:

function getCurrency(amount: number): string {
	return amount.toLocaleString("en", {
		style: "currency",
		currency: "USD",
	});
}

Now amount has to be a number, and the return value is a string. That small annotation changes the conversation. Your editor can autocomplete intelligently. The compiler can reject nonsense. Another person can read the signature and know how to call it.

That is TypeScript at its best: boring, local clarity that compounds across a codebase.

The Basic Vocabulary

The first layer is learning how to name the ordinary values you already use:

  • number
  • string
  • boolean
  • string[] or Array<string>
  • Promise<T>
  • void
  • any
  • unions like string | null

any deserves special suspicion. It is sometimes necessary, especially at the edges of a system, but it should feel expensive. It is the !important of TypeScript. If a value is any, the compiler stops helping. That may be the right tradeoff for a boundary, migration, or third-party escape hatch, but it should not become a habit.

Interfaces and Shapes

Interfaces describe the shape of an object.

interface LabeledValue {
	label: string;
	size: number;
}

function printLabel(labeledObj: LabeledValue): void {
	console.log(labeledObj.label);
}

printLabel({ size: 10, label: "Size 10 Object" });

This is sometimes called structural typing or duck typing. If the object has the right shape, TypeScript can treat it as satisfying the interface. You are not proving lineage. You are proving capability.

That is useful in JavaScript because so much code is built around plain objects. You can describe what a component expects, what an API returns, what a reducer state looks like, or what a utility function needs without inventing a class hierarchy.

Generics

Generics let a type be specific without being fixed in advance.

function createArray<T>(item: T, count: number): T[] {
	return Array(count).fill(item);
}

createArray<string>("thing", 5);

The function does not need to know whether the item is a string, number, object, or something else. It only needs to preserve the relationship: whatever goes in is the thing that fills the array, and the returned array contains that same thing.

That relationship is the value. Generics are not just angle bracket decoration. They let you describe how inputs and outputs move together.

interface LabeledValue {
	label: string;
	size: number;
}

createArray<LabeledValue>({ size: 10, label: "Size 10 Object" }, 5);

You can pass generics to functions, classes, and interfaces. In React, you see the same idea in reducers, refs, context, props, and state.

Enums and Named States

Enums give names to known values.

enum ThemeType {
	BLUE = "BLUE",
	GREEN = "GREEN",
	RED = "RED",
}

Instead of passing an arbitrary string, the code can ask for a known theme.

function getTheme(theme: ThemeType) {
	switch (theme) {
		case ThemeType.BLUE:
			return { background: "blue" };
		case ThemeType.RED:
			return { background: "red" };
		default:
			throw new Error("Must specify a supported theme");
	}
}

getTheme(ThemeType.BLUE);

Whether you use enums or literal unions, the goal is the same: constrain the possible states to the ones the application actually understands.

TypeScript and React

TypeScript made React exciting for me again because it made component contracts visible.

React is already built around composition. Components accept props, manage state, call hooks, and pass values through context. In plain JavaScript, a lot of those contracts live in your head, in docs, or in runtime checks. TypeScript lets the component surface area explain itself.

For JSX, use .tsx. Install the React community types if the project needs them. Prefer top-level imports, and let the types travel with the values they describe.

Context is a good example:

interface Theme {
	background: string;
	color: string;
}

interface ThemeContextValue {
	theme: Theme;
	onToggleTheme(theme: Theme): void;
}

const ThemeContext = React.createContext<ThemeContextValue | null>(null);

The context value now has a real shape. A provider has to satisfy it. A consumer can rely on it.

function ThemeProvider({ children }: { children: React.ReactNode }) {
	const [theme, setTheme] = React.useState<Theme>({
		background: "tomato",
		color: "white",
	});

	return <ThemeContext.Provider value={{ theme, onToggleTheme: setTheme }}>{children}</ThemeContext.Provider>;
}

The value passed into the provider cannot drift into some accidental object. If onToggleTheme takes a Theme, that is the function you have to provide.

Hooks

Hooks made stateful logic easier to share outside of classes. TypeScript makes the state being shared more deliberate.

const [name, setName] = React.useState<string>("Charles");
const [count, setCount] = React.useState<number>(0);
const [theme, setTheme] = React.useState<Theme>({
	background: "tomato",
	color: "white",
});

Often TypeScript can infer these types, and you do not need to annotate everything. But annotations are useful when the initial value is too narrow, nullable, or not representative of future values.

Refs have the same pattern:

function App() {
	const ref = React.useRef<HTMLButtonElement>(null);

	React.useEffect(() => {
		ref.current?.focus();
	}, []);

	return <button ref={ref}>Focus me</button>;
}

The important part is the union with null. The ref is not available until React attaches it. TypeScript makes you admit that before calling focus().

Reducers

Reducers are where TypeScript gets especially useful because reducers are all about allowed transitions.

useReducer gives you a tuple of state and dispatch:

const [state, dispatch] = React.useReducer(reducer, initialState);

The reducer should be a pure function. Given a current state and an action, it returns the next state.

interface State {
	items: string[];
	loading: boolean;
}

Typing the state is usually straightforward. Typing the action is where people get loose.

interface Action {
	type: string;
	payload?: string[];
}

This compiles, but it is weaker than it looks. It allows a payload when an action does not need one, and it allows the payload to be missing when an action does need one.

A discriminated union is better:

type Action =
	| {
			type: "FETCHING";
	  }
	| {
			type: "ADD_ITEMS";
			payload: string[];
	  };

Now the action type determines the payload shape. Dispatch gets safer:

dispatch({ type: "FETCHING" });
dispatch({ type: "ADD_ITEMS", payload: ["one", "two"] });

The compiler can reject invalid transitions before the code runs. That is the theme again: the application already had rules. TypeScript lets the rules live in the code.

The Larger Point

TypeScript is not valuable because every line gets more syntax. It is valuable when it helps you make the code more intentional.

Good types are documentation that stays attached to the program. They help your editor. They help your tests by catching entire categories of mistakes earlier. They help future-you understand what past-you meant.

The best TypeScript does not feel like ceremony. It feels like the code taking notes on itself.