Typescript Deep Dive
11 September 2025
Updated: 30 March 2026
This was the original version of the Type First Development Talk but after the first dry run this looked like it was going to melt people’s brains and so it was significantly trimmed down. But here’s the rough and unadulterated version of the talk - mostly intended as a quick reference on various TS topics
ZOOOOOOOM
Me
Hi, I’m Nabeel Valley
Type-first development with Typescript
- Types as a base
- Business logic as transformations
- Compiler guides implementation
Runtime vs Compile Time
Types don’t exist at runtime!
- The compiler checks types
- Unit tests check runtime
- Type-only changes cannot create bugs
- Low risk to add
- Low risk to remove
The more we can put into our types, the less we need to test
Good Types
Good types allow us to:
- Eliminate incorrect application state
- Enforce business logic
- Enable composition of business concepts
- Simplify testing
- Document code and processes
- Make Autocomplete really nice
A Running Thread
A Simple Multi-Step Form:
- Input Amount
- Select Account From
- Select Account To
- Confirm + Send
How can we model this using types?
An Initial Model
How do we define the data that our form holds?
1type TransferForm = {2 amount: number3 accountFrom: string4 accountTo: string5 confirmation?: boolean6}Interrogate the Model
What questions arise from our model?
1// does this represent a completed form or an in-progress one?2type TransferForm = {3 // is this any number?4 amount: number5
6 // do these strings have some specific format?7 // how does this related to other data?8 // can i fill these in before an amount is selected?9 accountFrom: string10 accountTo: string11
12 // what does it mean if this is `undefined`?13 // does it make sense for this to be `true` if the form is incomplete?14 confirmation?: boolean15}Re-thinking our types
- Is this really just a
stringornumber - Are these values actually optional
- Is there some validation that needs to be applied here?
- Are there relationships with other data?
Can we better type our primitives?
1type TransferAmount = number //??Branded Types
- A wrapper over primitive values
- Provides domain context
- Enables more robust validation
- Do not add runtime overhead
What is an Amount?
What business rules do we have around this?
- Represents €
- Must be positive
- Must be less than 1 000 000
Defining a Brand
- Do not modify the original object
- Can be defined in a variety of ways
- Exist only at the type level
- Store validation in the type language
- Usually consist of
- A primitive value
- A private brand indicator
Defining Branded Types
1// create a compile-time-only value2declare const EuroBrand: unique symbol3
4type Euro = number & { [EuroBrand]: true }5
6declare const TransferAmounBrand: unique symbol7
8// a transfer amout is a Euro + some validation9type TransferAmount = Euro & { [TransferAmounBrand]: true }Creating a Euro Amount
Branding is usually done via a function
1// We can create a Euro value from any number2function euro(value: number): Euro {3 // simply casts the value, does not modify it4 return value as Euro5}Validation
Once we have a Euro value, we can determine if it’s a valid transfer amount
- We want to encode this validation in the Types
- Type predicates let us check that something is a given type
- Assertion functions let us throw if it is not a given type
Type Predicates
- Conditionally narrow the type of something
1// We can check if a Euro value is a Valid Transfer Amount2function isTransferAmount(amount: Euro): amount is TransferAmount {3 return amount > 0 && amount < 1_000_0004}Using a Type Predicate
Predicates prevent us from doing type-specific things in places where the result is not true:
1function doSomething(amount: Euro) {2 if (isTransferAmount(amount)) {3 // No Error4 const valid: TransferAmount = amount5 }6
7
8 // @ts-expect-error Error: Type 'Euro' is not assignable to type 'TransferAmount'9 const invalid: TransferAmount = amount10}Asssertion Functions
Throw if a type requirement is not met, also narrows the type of something
1function assertTransferAmount(amount: Euro): asserts amount is TransferAmount {2 if (isTransferAmount(amount)) {3 return4 }5
6 throw new Error("Invalid amount received")7}Using an Assertion Function
The value we’re working with is a valid type after the assertion function has been called
1function doSomethingOrThrow(amount: Euro) {2 assertTransferAmount(amount)3
4 // would have thrown if not a valid TransferAmount5 const valid: TransferAmount = amount6
7 console.log(valid.toFixed(2))8}Relationships Between Types
Can we narrow an account down more specifically than string?
1type AccountReference = string //??Deriving Types
- Single source of truth
- Identify relationships between types
Generic Types
- Have input arguments, and an output type
- Allow us to compose types from other types
Generic types are like functions that work at the type level
Builtin Generic Types
- Typescript has a bunch of Generic types builtin
- All builtin types can be re-created from scratch
1// makes all properties optional2type PartialData = Partial<Data>3
4// makes all properties required5type RequiredData = Required<Data>6
7// selects specific properties of data8type PickedData = Pick<Data, 'ammount' | 'accountFrom'>Defining a Reference Type
A generic type that lets us take anything that looks like a Model and get it’s ID
1// a model is something with an id2interface Model {3 id: unknown4}5
6// a "reference" is based on the type of the id of a Model7// the `extends` keyword here works as a constraint8type Reference<M extends Model> = M['id']9
10// very similar to a generic function that would do the same11function reference<M extends Model>(model: M): Reference<M> {12 return model.id13}Deriving the Account Type
We can use the type function with our AccountModel
1type AccountModel = {2 id: `ACC-${number}`3}4
5type AccountRef = Reference<AccountModel>6
7// similarly we can preload the generic function8const accountReference = reference<AccountModel>9// ^? (model: AccountModel) => Reference<AccountModel>If
AccountModelever changes,AccountRefwill also change
Question the Domain
- Is this value actually this type
- Are there any states I’m not thinking about
- Are there potential conflicts with how this is used?
What about the Confirmation
1type Confirmation = boolean | undefinedWe don’t always need fancy types, usually we just need to think about what we have
- Initially represented as an optional boolean:
- What if it’s undefined? Do we really only have two states?
- Is
undefinedthe same asfalse- Assume that
false == deniedandundefined == not yet selected
- Assume that
Union Types
- Represents one of a fixed list of types
- Useful for representing a set of explicit states
1type Confirmation = 'pending' | 'accepted' | 'denied'The Updated Model
We can define the model we had above a bit more concretely now:
1type TransferForm = {2 amount: TransferAmount3 accountFrom: AccountRef4 accountTo: AccountRef5 confirmation: Confirmation6}This clears up any questions we had about the field
A Detour through the World of Types
So far we’ve covered:
- Branded Types
- Generic Types
- Assertion Functions
- Type Predicates
- Generic Types
- Union Types
But, before we can model the rest of the process, we need a few more tools
What does our form need?
- Multi-step form wizard
- Each step gets data from different fields
- We want steps to be type-safe
- State should be valid at all times
We need a few more tools to make this all posssible
The more advanced stuff
Typescript provides us with mechanisms for defining complex types
TODO not all covered
keyofextracts keys from objects- Mapped types allow mapping from properties of one type onto another
- Key mapping
{ [P in keyof T & string as Capitalize<P>]: true } - Key modifiers
{ readonly [P in K]: true }or{ readonly [P in K]?: true }or{ readonly [P in K]-?: true }
- Key mapping
extendschecks if a type conforms to another typeinferlets us infer a precise type from a less precise type- Recursion enables complex composition of types
- Readonly types
satisfiesas const- Implementations of internal types
- e.g
ConstructorParameterswithabstract new(...) intrinsickeyword
- e.g
- Template literal types limitations: https://github.com/microsoft/TypeScript/pull/40336
NoInferhttps://www.totaltypescript.com/noinfer- Exceptions to
any typeofand Deriving types from values- https://www.totaltypescript.com/any-considered-harmful#type-argument-constraints
- e.g. how we can use
type X = (typeof xs)[number]thenisX = (maybe): maybe is X => xs.includes(maybe)
- Variance Annotations
interface Thing<in out T> { make():T }https://www.typescriptlang.org/docs/handbook/2/generics.html#variance-annotations
This is all more easily illustrated by example
The keyof keyword
keyoflets us get the keys of a given object
Keyof is the simplest of the generic tools and looks like so:
1type TransferForm = {2 amount: TransferAmount3 accountFrom: AccountRef4 accountTo: AccountRef5 confirmation: Confirmation6}7
8type TransferFormFields = keyof TransferForm9// ^? 'amount' | 'accountFrom' | 'accountTo' | 'confirmation'Mapped types
- Mapped types allow mapping from properties of one type onto another
- Used with
keyofto iterate through the properties of an object
1type TransferFormLabels = {2 // consists of mapping some set of keys to some set of values3 [K in keyof TransferForm]: Capitalize<K>4}5
6// this is the only valid value for `labels` according to this type definition7const labels: TransferFormLabels = {8 amount: 'Amount',9 accountFrom: 'AccountFrom',10 accountTo: 'AccountTo',11 confirmation: 'Confirmation'12}The extends keyword
extendschecks if a type conforms to another type- Like a conditional expression for types
This is different to the
extendskeyword when working with classes
1type IsNumber<X> = X extends number ? 'yes' : 'no'2// kind of like: x => typeof x == 'number' ? 'yes' : 'no'3
4type Is5Number = IsNumber<5>5// ^? 'yes'6
7type IsTrueNumber = IsNumber<true>8// ^? 'no'The infer keyword
inferlets us infer a precise type from a less precise type- Must be used with
extends - Like asking the compiler:
- What can I put here to make this statement true
1type PrefixOf<S> = S extends `${infer S}_${string}` ? S : never2// like asking the compiler: ^ what goes here to make this true3
4type HelloPrefix = PrefixOf<"hello_world">5// ^? "hello"6//7type ByePrefix = PrefixOf<"bye_world">8// ^? "bye"Recursive Types
Generic types are like functions, so we can use them in very similar ways
- The type system is immutable
- Recursion allows us to iterate without mutation
- Try to keep small to isolate complexity
Comparison with Functions
Recursion in types is pretty much the same as using it in a normal function
A recursive function to check if a list has x in it could look like this:
1function hasX(arr: string[]): boolean {2 if (arr.length > 0) {3 const [first, ...rest] = arr4
5 return first === 'x' ? true : hasX(rest)6 } else {7 return false8 }9}10
11const axcHasX = hasX(['a', 'x', 'c'])12// ?^ true13
14const abcHasX = hasX(['a', 'b', 'c'])15// ?^ falseRecursive functions always need an exit condition to stop recursion
Expressing as Types
As a type, the same function is quite similar
1type HasX<Arr> =2 Arr extends [infer First, ...infer Rest]3 ? First extends 'x'4 ? true5 : HasX<Rest>6 : false7
8
9type AXCHasX = HasX<['a', 'x', 'c']>10// ?^ true11
12type ABCHasX = HasX<['a', 'b', 'c']>13// ?^ falseRecursive types also need an exit condition to stop recursion
Back to Business
Is there a way that we can model statefully building this object up?
Can we use these new tools to model the multi-form process?
Modeling the Process
Types can be used to model processes and how data moves through them
So now we’ve got the model, can we think about how it’s built
As mentioned before, we have a multi-step form:
- Input Amount
- Select Account From
- Select Account To
- Confirm
- Submit
At each step, the data must be valid before moving to the next
Relate the Model to the Process
Looking at our steps, we can break the model into these parts:
1type TransferForm = {2 // at the end of step 1, this is defined3 amount: TransferAmount4
5 // at the end of step 2, this is defined6 accountFrom: AccountRef7 accountTo: AccountRef8
9 // at the end of step 3, this is defined10 confirmation: Confirmation11}Defining a Single Step
- The form is made up of multiple steps
- Data from steps are merged together to create the final model
1// Each step has the name of the step + the data that results from that step2type Step<3 Label extends string = string,4 Data extends Record<string, unknown> = Record<string, unknown>,5> = {6 label: Label;7 data: Data8 }Defining the Steps
- The data from each step of the form in isolation
1type InitStep = Step<'Init', {}>2// ^? { label: "Init"; data: {}; }3
4type AmountStep = Step<'Amount', Pick<TransferForm, 'amount'>>;5// ^? { label: "Amount", data: { amount: TransferAmount} }6
7type AccountStep = Step<'Accounts', Pick<TransferForm, 'accountFrom' | 'accountTo'>>;8// ^? { label: "Accounts", data: { accountFrom: AccountRef, accountTo: AccountRef } }9
10type ConfirmationStep = Step<'Confirmation', Pick<TransferForm, 'confirmation'>>;11// ^? { label: "Accounts", data: { confirmation: Confirmation } }12
13type CompleteStep = Step<'Complete', {}>14// ^? { label: "Init"; data: {}; }Defining the Process
- The form is made of multiple steps
- Data from steps are merged together to create the final model
Merging Data from Steps
- The next step should be merged with the data of the previous step
We can describe this process as a type:
1type MergeSteps<Current extends Step, Previous extends Step> = {2 // the name is for the step we're currently on3 name: Current['name'],4 // data adds onto the previous step5 data: Previous['data'] & Current['data']6}Representing All Steps
- A form is a set of steps
- Data from each step should be merged with the next
- Each step + data combination is distinct
1type TransferFormState = unknown // union of possible statesLet’s quickly look at what we want our steps to look like
Initially, Account Step
At the start of the form we are in the Amount step, during this phase the user has not provided any data yet
1const step1 = {2 "label": "Amount",3 "data": {}4}Account Step
During this phase, we only have the amount
1const step2 = {2 "label": "Accounts",3 "data": {4 "amount": 1005 }6},Confirmation Step
Once the accounts are selected, this should be added to the data from the previous step, and so now we
1const step3 = {2 label: 'Confirmation',3 data: {4 ...step2.data,5 accountFrom: accountReference(accountFrom),6 accountTo: accountReference(accountTo),7 }8}After Confirming
Once the status has been confirmed, this should be added to the data
1const step4 = {2 label: 'Complete',3 data: {4 ...step3.data,5 confirmation: 'accepted'6 },7}Using this Practically
1export class App {2 // signal that represents each of the specific states we have3 state = signal<TransferFormState>({4 label: 'Amount',5 data: {}6 });7
8 // rest of implementation9}
TransferFormStatecan be a union of the above steps. This is usually good enough
Keeps our Templates Clean
Using a
switchwill ensures that this is all fully type-checked and valid
1 @let s = state();2
3@switch (s.label) {4 @case('Amount') {5 <app-amount-step (onSubmit)="handleAmountSubmit($event)" />6 }7 @case ('Accounts') {8 <app-account-step (onSubmit)="handleAccountsSubmit($event)"/>9 }10 @case ('Confirmation') {11 <app-confirmation-step12 [accountFrom]="s.data.accountFrom"13 [accountTo]="s.data.accountTo"14 [amount]="s.data.amount"15 (onSubmit)="handleConfirmationSubmit($event)"/>16 }17 @case ('Complete') {18 <app-completed-message [confirmation]="s.data.confirmation()" />19 }20}Stepping is Simple
- A step is simply a merging of the previous data with the new once, after ensuring we’re in the step we expect
This is also fully type guarded
1handleAmountSubmit(data: AmountStep['data']) {2 const state = this.state();3 if (state.label !== 'Amount') {4 return;5 }6
7 this.state.set(8 {9 label: 'Accounts',10 data: {11 ...state.data,12 ...data13 },14 },15 );16}How Far Should we Go?
- This is probably enough for most implementations
- Use a union type if this is a once-off
Thinking Like A Library Author
- What if this is meant to be shared?
- How can we provide a good interface for consumers?
- Is complexity okay if it’s contained?
Reduce Repeated Types
- Lots of repetition to define all those states manually
- Easy to make a mistake
- This type can be complex to define
We can use the tools we’ve covered to make this more automatic
How Does Stepping Look as a Function?
- It’s helpful to look at Complex Types as Functions
- Return of the recursion
- Since we can’t use loops in types
Apologies in advance for the big code snippet you’re about to see
1function multiStepFormStates(steps: Step[], merged: Step[] = [steps[0]]) {2 // no more steps to merge, so return3 if (steps.length < 2) {4 return merged5 }6
7 const [current, next, ...rest] = steps8
9 // each state starts off with only the data from a previous state10 const start: Step = {11 label: next.label,12 data: {13 ...current.data,14 }15 }16
17 // each step builds on the end state of the next state18 const end: Step = {19 label: next.label,20 data: {21 ...current.data,22 ...next.data23 }24 }25
26 return multiStepFormStates(27 // pass rest of entries to recurse through28 [end, ...rest],29 // pass results forward30 [...merged, start]31 )32}Putting the Types Together
1type MultiStepFormStates<Steps extends Step[], Merged extends Step[] = [Steps[0]]> = Steps extends [2 infer Current,3 infer Next,4 ...infer Rest,5]6 ? Rest extends Step[]7 // recursive "call" to the "type function"8 ? MultiStepFormStates<9 // use the completed state of the current phase as the starting point for the next10 [EndTransition<Current, Next>, ...Rest],11 // store the start of each step as this is what the step will have12 [...Merged, StartTransition<Current, Next>]13 >14 // rest does not contain steps, this should `never` happen15 : never16 // processed all items, return final result17 : Merged[number]Creating the Form Type
Using the MultiStepFormStates type, the TransferFormStates can be defined as follows
1export type TransferFormState = MultiStepFormStates<2 [InitStep, AmountStep, AccountStep, ConfirmationStep, CompleteStep]3>;This type is also generic for any multi-step in the application!
Implications of Strict State
Having a strictly controlled state like this means that:
- We don’t need to re-validate data
- A valid state implies that all previous validation was done
- The type system will not allow missing/partial data
Inferring Validity
The state is guaranteed to be valid if we’re on a given step
Extracting the final data of the form can be done using the state alone without rev-validating
1// we can now do state name checks2if (state.name === 'confirmation') {3 // valid states mean we don't need to re-validate4 submitForm(state.data)5}Compiler Guarantees
- Controlled types means the compiler is our tester
- We don’t need to test code with invalid types
- Scopes tests to specifically
- Complex logic
- Type-casting code
Using
anyin your code takes away these guarantees
Risks
- Types can have a high complexity-density
- Small code, big scary (sometimes)
- Overly strict types may be difficult for developers to work with
anycan destroy quality of inferred types
Recap
Interrogate Your Models
- Question why fields in your model are the type they are
- Define core truths and relationships between models
- Think about what data types can accurately represent them
Look for Composition
- Relationships exist between our models and how we use them
- Typescript has tools for representing these relationships
Methods of Composing Types
- Branded Types
- Generic Types
- Assertion Functions
- Type Predicates
- Generic Types
- Union Types
Tools For Building Complex Types
Complex types have some similar structures, learn once - use forever
- Mapped Types
- Recursive Types
keyofkeywordextendskeywordinferkeyword
Why Bother?
- Single source of truth, less to maintain
- The compiler tells you when things break
- Reduced needs for unit testing
- Types document processes
- Great Autocomplete for other Devs
- Low risk
- Complex types can be replaced with simpler types
- No runtime impact, type-only refactors can’t introduce bugs
References & Further Reading
- https://nabeelvalley.co.za/talks/2024/13-03/check-your-domain
- https://www.learningtypescript.com/articles/branded-types
- https://www.lucaspaganini.com/academy/assertion-functions-typescript-narrowing-5/
- https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
- https://www.typescriptlang.org/docs/handbook/utility-types.html