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:

  1. Input Amount
  2. Select Account From
  3. Select Account To
  4. Confirm + Send

How can we model this using types?

An Initial Model

How do we define the data that our form holds?

1
type TransferForm = {
2
amount: number
3
accountFrom: string
4
accountTo: string
5
confirmation?: boolean
6
}

Interrogate the Model

What questions arise from our model?

1
// does this represent a completed form or an in-progress one?
2
type TransferForm = {
3
// is this any number?
4
amount: number
5
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: string
10
accountTo: string
11
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?: boolean
15
}

Re-thinking our types

  • Is this really just a string or number
  • 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?

1
type 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 value
2
declare const EuroBrand: unique symbol
3
4
type Euro = number & { [EuroBrand]: true }
5
6
declare const TransferAmounBrand: unique symbol
7
8
// a transfer amout is a Euro + some validation
9
type TransferAmount = Euro & { [TransferAmounBrand]: true }

Creating a Euro Amount

Branding is usually done via a function

1
// We can create a Euro value from any number
2
function euro(value: number): Euro {
3
// simply casts the value, does not modify it
4
return value as Euro
5
}

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 Amount
2
function isTransferAmount(amount: Euro): amount is TransferAmount {
3
return amount > 0 && amount < 1_000_000
4
}

Using a Type Predicate

Predicates prevent us from doing type-specific things in places where the result is not true:

1
function doSomething(amount: Euro) {
2
if (isTransferAmount(amount)) {
3
// No Error
4
const valid: TransferAmount = amount
5
}
6
7
8
// @ts-expect-error Error: Type 'Euro' is not assignable to type 'TransferAmount'
9
const invalid: TransferAmount = amount
10
}

Asssertion Functions

Throw if a type requirement is not met, also narrows the type of something

1
function assertTransferAmount(amount: Euro): asserts amount is TransferAmount {
2
if (isTransferAmount(amount)) {
3
return
4
}
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

1
function doSomethingOrThrow(amount: Euro) {
2
assertTransferAmount(amount)
3
4
// would have thrown if not a valid TransferAmount
5
const valid: TransferAmount = amount
6
7
console.log(valid.toFixed(2))
8
}

Relationships Between Types

Can we narrow an account down more specifically than string?

1
type 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 optional
2
type PartialData = Partial<Data>
3
4
// makes all properties required
5
type RequiredData = Required<Data>
6
7
// selects specific properties of data
8
type 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 id
2
interface Model {
3
id: unknown
4
}
5
6
// a "reference" is based on the type of the id of a Model
7
// the `extends` keyword here works as a constraint
8
type Reference<M extends Model> = M['id']
9
10
// very similar to a generic function that would do the same
11
function reference<M extends Model>(model: M): Reference<M> {
12
return model.id
13
}

Deriving the Account Type

We can use the type function with our AccountModel

1
type AccountModel = {
2
id: `ACC-${number}`
3
}
4
5
type AccountRef = Reference<AccountModel>
6
7
// similarly we can preload the generic function
8
const accountReference = reference<AccountModel>
9
// ^? (model: AccountModel) => Reference<AccountModel>

If AccountModel ever changes, AccountRef will 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

1
type Confirmation = boolean | undefined

We 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 undefined the same as false
    • Assume that false == denied and undefined == not yet selected

Union Types

  • Represents one of a fixed list of types
  • Useful for representing a set of explicit states
1
type Confirmation = 'pending' | 'accepted' | 'denied'

The Updated Model

We can define the model we had above a bit more concretely now:

1
type TransferForm = {
2
amount: TransferAmount
3
accountFrom: AccountRef
4
accountTo: AccountRef
5
confirmation: Confirmation
6
}

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

This is all more easily illustrated by example

The keyof keyword

  • keyof lets us get the keys of a given object

Keyof is the simplest of the generic tools and looks like so:

1
type TransferForm = {
2
amount: TransferAmount
3
accountFrom: AccountRef
4
accountTo: AccountRef
5
confirmation: Confirmation
6
}
7
8
type TransferFormFields = keyof TransferForm
9
// ^? 'amount' | 'accountFrom' | 'accountTo' | 'confirmation'

Mapped types

  • Mapped types allow mapping from properties of one type onto another
  • Used with keyof to iterate through the properties of an object
1
type TransferFormLabels = {
2
// consists of mapping some set of keys to some set of values
3
[K in keyof TransferForm]: Capitalize<K>
4
}
5
6
// this is the only valid value for `labels` according to this type definition
7
const labels: TransferFormLabels = {
8
amount: 'Amount',
9
accountFrom: 'AccountFrom',
10
accountTo: 'AccountTo',
11
confirmation: 'Confirmation'
12
}

The extends keyword

  • extends checks if a type conforms to another type
  • Like a conditional expression for types

This is different to the extends keyword when working with classes

1
type IsNumber<X> = X extends number ? 'yes' : 'no'
2
// kind of like: x => typeof x == 'number' ? 'yes' : 'no'
3
4
type Is5Number = IsNumber<5>
5
// ^? 'yes'
6
7
type IsTrueNumber = IsNumber<true>
8
// ^? 'no'

The infer keyword

  • infer lets 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
1
type PrefixOf<S> = S extends `${infer S}_${string}` ? S : never
2
// like asking the compiler: ^ what goes here to make this true
3
4
type HelloPrefix = PrefixOf<"hello_world">
5
// ^? "hello"
6
//
7
type 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:

1
function hasX(arr: string[]): boolean {
2
if (arr.length > 0) {
3
const [first, ...rest] = arr
4
5
return first === 'x' ? true : hasX(rest)
6
} else {
7
return false
8
}
9
}
10
11
const axcHasX = hasX(['a', 'x', 'c'])
12
// ?^ true
13
14
const abcHasX = hasX(['a', 'b', 'c'])
15
// ?^ false

Recursive functions always need an exit condition to stop recursion

Expressing as Types

As a type, the same function is quite similar

1
type HasX<Arr> =
2
Arr extends [infer First, ...infer Rest]
3
? First extends 'x'
4
? true
5
: HasX<Rest>
6
: false
7
8
9
type AXCHasX = HasX<['a', 'x', 'c']>
10
// ?^ true
11
12
type ABCHasX = HasX<['a', 'b', 'c']>
13
// ?^ false

Recursive 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:

  1. Input Amount
  2. Select Account From
  3. Select Account To
  4. Confirm
  5. 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:

1
type TransferForm = {
2
// at the end of step 1, this is defined
3
amount: TransferAmount
4
5
// at the end of step 2, this is defined
6
accountFrom: AccountRef
7
accountTo: AccountRef
8
9
// at the end of step 3, this is defined
10
confirmation: Confirmation
11
}

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 step
2
type Step<
3
Label extends string = string,
4
Data extends Record<string, unknown> = Record<string, unknown>,
5
> = {
6
label: Label;
7
data: Data
8
}

Defining the Steps

  • The data from each step of the form in isolation
1
type InitStep = Step<'Init', {}>
2
// ^? { label: "Init"; data: {}; }
3
4
type AmountStep = Step<'Amount', Pick<TransferForm, 'amount'>>;
5
// ^? { label: "Amount", data: { amount: TransferAmount} }
6
7
type AccountStep = Step<'Accounts', Pick<TransferForm, 'accountFrom' | 'accountTo'>>;
8
// ^? { label: "Accounts", data: { accountFrom: AccountRef, accountTo: AccountRef } }
9
10
type ConfirmationStep = Step<'Confirmation', Pick<TransferForm, 'confirmation'>>;
11
// ^? { label: "Accounts", data: { confirmation: Confirmation } }
12
13
type 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:

1
type MergeSteps<Current extends Step, Previous extends Step> = {
2
// the name is for the step we're currently on
3
name: Current['name'],
4
// data adds onto the previous step
5
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
1
type TransferFormState = unknown // union of possible states

Let’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

1
const step1 = {
2
"label": "Amount",
3
"data": {}
4
}

Account Step

During this phase, we only have the amount

1
const step2 = {
2
"label": "Accounts",
3
"data": {
4
"amount": 100
5
}
6
},

Confirmation Step

Once the accounts are selected, this should be added to the data from the previous step, and so now we

1
const 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

1
const step4 = {
2
label: 'Complete',
3
data: {
4
...step3.data,
5
confirmation: 'accepted'
6
},
7
}

Using this Practically

1
export class App {
2
// signal that represents each of the specific states we have
3
state = signal<TransferFormState>({
4
label: 'Amount',
5
data: {}
6
});
7
8
// rest of implementation
9
}

TransferFormState can be a union of the above steps. This is usually good enough

Keeps our Templates Clean

Using a switch will 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-step
12
[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

1
handleAmountSubmit(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
...data
13
},
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

1
function multiStepFormStates(steps: Step[], merged: Step[] = [steps[0]]) {
2
// no more steps to merge, so return
3
if (steps.length < 2) {
4
return merged
5
}
6
7
const [current, next, ...rest] = steps
8
9
// each state starts off with only the data from a previous state
10
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 state
18
const end: Step = {
19
label: next.label,
20
data: {
21
...current.data,
22
...next.data
23
}
24
}
25
26
return multiStepFormStates(
27
// pass rest of entries to recurse through
28
[end, ...rest],
29
// pass results forward
30
[...merged, start]
31
)
32
}

Putting the Types Together

1
type 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 next
10
[EndTransition<Current, Next>, ...Rest],
11
// store the start of each step as this is what the step will have
12
[...Merged, StartTransition<Current, Next>]
13
>
14
// rest does not contain steps, this should `never` happen
15
: never
16
// processed all items, return final result
17
: Merged[number]

Creating the Form Type

Using the MultiStepFormStates type, the TransferFormStates can be defined as follows

1
export 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 checks
2
if (state.name === 'confirmation') {
3
// valid states mean we don't need to re-validate
4
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 any in 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
  • any can 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
  • keyof keyword
  • extends keyword
  • infer keyword

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