Type-first development
15 September 2025
Updated: 23 December 2025
ZOOOOOOOM
Code + Demo Next to Each Other
Me
Hi, I’m Nabeel Valley
What we’re going to talk about
- Why types matter
- Effectively modeling your data
- Using types to prevent invalid states
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
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
Reference Example
We’ll use a multi-step form as an example usecase:
- Input amount
- Select accounts
- Confirm details
- Submit
Part 1 - The Initial Form
Demo - Base
Code + Demo running alongside each other
An Initial Model
How do we define the data that our form holds?
1type TransferForm = {2 amount: number3 accountFrom: string4 accountTo: string5 confirmation?: boolean6}Part 2 - Code is Always Changing
- Adding features to existing code is challenging
- When our types aren’t good, we may not catch some bugs
Demo - Bug 1
- Changing requirements
- Add a new step to the form
- Backend requires that amount is in between
0and1 000 000
Code + Demo running alongside each other
Where is the bug?
- During the input validation?
- Between our state changes?
- During form submission?
Loose types make it difficult to track down sources of invalid data
Investigate the Model
Can I see the business rules easily from this type alone?
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}Evolving our Types
Can we represent our data better so we can more easily follow it through our application?
Can we better type our primitives?
1type TransferAmount = number //??Branded Types
- Provides domain context to type
- Enables more robust validation
- Do not add runtime overhead
- Usually consist of
- A primitive value
- A private brand indicator
What is an Amount?
What business rules do we have around this?
- Represents €
- Must be positive
- Must be less than 1 000 000
Defining Branded Types
1// create a compile-time-only value2declare const EuroBrand: unique symbol3
4type Euro = number & { [EuroBrand]: true }5
6declare const TransferAmountBrand: unique symbol7
8// a transfer amout is a Euro + some validation9type TransferAmount = Euro & { [TransferAmountBrand]: 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}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'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, 'amount' | '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
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
Update the Types
- Updated all code to use the new model
- Can we rely on the compiler for any hints about our bug now?
My Compiler is Sad
We’ve made updates to all our types, but now the compiler has some issues
1✘ [ERROR] TS2345: Argument of type 'undefined' is not assignable to parameter of type 'Confirmation'. [plugin angular-compiler]2
3 src/app/accounts-service.ts:24:40:4 24 │ const result = signal<Confirmation>(undefined)5
6✘ [ERROR] TS2322: Type 'Euro' is not assignable to type 'TransferAmount'.7 Property '[TransferAmountBrand]' is missing in type 'Number & { [EuroBrand]: true; }' but required in type '{ [TransferAmountBrand]: true; }'. [plugin angular-compiler]8
9 src/app/amount-step/amount-step.ts:53:25:10 53 │ this.onSubmit.emit({ amount });11 ╵ ~~~~~~12
13 '[TransferAmountBrand]' is declared here.14
15 src/app/types.ts:15:38:16 15 │ export type TransferAmount = Euro & { [TransferAmountBrand]: true };17 ╵ ~~~~~~~~~~~~~~~~~~~~18
19 The expected type comes from property 'amount' which is declared here on type 'Pick<TransferForm, "amount">'20
21 src/app/types.ts:47:2:22 47 │ amount: TransferAmount;23 ╵ ~~~~~~Quick Fixes
- The compiler tells us where something isn’t what it expects
- We can add the necessary validation and initialization
Demo - Bug 2
- Later on, we do some refactoring
- We now see that the form ordering is broken for some reason
- Fix this issue
Code + Demo running alongside each other
How can prevent this?
Looking at our form’s code we see this
1label = signal<'Amount' | 'Accounts' | 'Complete' | 'Confirmation'>('Amount');2state = signal<Partial<Types.TransferForm>>({});- There isn’t a clear definition of the different states the form can be in
- Can we tie all the valid states together to ensure things remain in sync?
Modeling the Process
- The form is made of distinct steps
- Each state builds up the form’s state
Relate the Model to the Process
Looking at our steps, we can break the model into these parts:
1// at the start, we have an empty object2type TransferForm = {3 // at the end of step 1, this is defined4 amount: TransferAmount5
6 // at the end of step 2, this is defined7 accountFrom: AccountRef8 accountTo: AccountRef9
10 // at the end of step 3, this is defined11 confirmation: Confirmation12}Defining a Single Step
- The form is made up of multiple steps
- Data from steps are merged together to create the final model
- Each step has a name so we can easily distinguish
1// Each step has the name of the step + the data that results from that step2type Step<3 Label extends string,4 Data extends Record<string, unknown>5> = {6 label: Label;7 data: Data8 }Defining the Steps
The step we’re in does not include the data resulting from that step
1type AmountStep = Step<'Amount', {}>;2
3type AccountStep = Step<'Accounts', Pick<TransferForm, 'amount'>>;4
5type ConfirmationStep = Step<'Confirmation', Pick<TransferForm, 'amount' | 'accountFrom' | 'accountTo'>>;6
7type CompleteStep = Step<'Complete', TransferForm>Possible Form States
1type TransferFormState = AmountStep | AccountStep | ConfirmationStep | CompleteStepDemo - Strict
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
Code + Demo running alongside each other
How does the Compiler Protect Us?
- Making changes to the states will break during compilation
- We don’t need to unit test the invalid states that were previously possible
- When types are strict we can rely on the compiler to catch cases we’ve missed
The amount of benefit you get depends on the strictness of your types
How Far Should we Go?
- This is probably enough for most implementations
- Use a union type if this is a once-off
- Keep your types simple
- Different teams have different appetites for complexity
- Unnecessary complexity can impact
Thinking Like A Library Author
Types can get more complex as our usecases become more generic
Some tools for highly generic types that are also handy to be aware of:
- Mapped types and Recursion for deriving complex types
extendsfor conditional typesinferfor asking the compiler for information
Using Any
anywill destroy all benefits of strong types
- It tells the compiler to ignore type checking for a specific value
- This allows unsafe data to move through code that should be safe
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
Create and Compose Good Types
- Branded Types
- Generic Types
- Assertion Functions
- Type Predicates
- Union Types
Model Your Business Processes
- Tightly model how your state changes and moves through a system
- Leverage the compiler by giving it as much information as possible
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