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 any in your code takes away these guarantees

Reference Example

We’ll use a multi-step form as an example usecase:

  1. Input amount
  2. Select accounts
  3. Confirm details
  4. 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?

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

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 0 and 1 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?
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
}

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?

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

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'

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, '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 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

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

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

1
label = signal<'Amount' | 'Accounts' | 'Complete' | 'Confirmation'>('Amount');
2
state = 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 object
2
type TransferForm = {
3
// at the end of step 1, this is defined
4
amount: TransferAmount
5
6
// at the end of step 2, this is defined
7
accountFrom: AccountRef
8
accountTo: AccountRef
9
10
// at the end of step 3, this is defined
11
confirmation: Confirmation
12
}

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

Defining the Steps

The step we’re in does not include the data resulting from that step

1
type AmountStep = Step<'Amount', {}>;
2
3
type AccountStep = Step<'Accounts', Pick<TransferForm, 'amount'>>;
4
5
type ConfirmationStep = Step<'Confirmation', Pick<TransferForm, 'amount' | 'accountFrom' | 'accountTo'>>;
6
7
type CompleteStep = Step<'Complete', TransferForm>

Possible Form States

1
type TransferFormState = AmountStep | AccountStep | ConfirmationStep | CompleteStep

Demo - 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
  • extends for conditional types
  • infer for asking the compiler for information

Using Any

any will 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