← All articles

We keep telling engineering teams to shift left: test earlier, validate earlier, automate earlier, and catch defects before they reach production. Good advice. But follow that logic far enough and you arrive at a more interesting question:

Why detect a failure earlier if you can make that failure impossible to express?

Not every failure, obviously. Networks fail, dependencies fail, requirements are misunderstood, authorization changes, and browsers behave like browsers. Reality will always find new ways to surprise us.

But some failures are different. They exist only because our code allows us to express something that should never have been valid. Those failures don't need earlier tests; they need stronger constraints. Once you see testing that way, TypeScript, branded types, and Playwright start to play very different roles.


Make invalid states impossible

The browser should never have seen this bug

Imagine a Playwright framework for an insurance application. It deals with customers, policies, and claims:

type CustomerId = string;
type PolicyId = string;
type ClaimId = string;

It looks typed, but it isn't — at least not in the way the domain cares about. To TypeScript, these are all compatible strings, so this is perfectly legal:

const customerId: CustomerId = "CUS-123";

async function openPolicy(
  policyId: PolicyId
): Promise<void> {
  // ...
}

await openPolicy(customerId);

And now the machinery starts. Chromium launches, the application loads, authentication happens, and requests are made. Perhaps the backend returns a 404. Perhaps the UI never reaches the expected state. Perhaps Playwright waits until it eventually gives us everyone's favourite diagnosis:

Timeout 30000ms exceeded.

Wonderful. We have just spent a browser session discovering that we passed the wrong kind of ID to a function. The interesting question isn't:

How can we detect this faster?

It is:

Why was this program allowed to run at all?

Make the mistake unrepresentable

Now give those strings domain meaning:

declare const brand: unique symbol;

type Brand<T, Name extends string> =
  T & { readonly [brand]: Name };

type CustomerId = Brand<string, "CustomerId">;
type PolicyId = Brand<string, "PolicyId">;
type ClaimId = Brand<string, "ClaimId">;

Try the same thing again:

declare const customerId: CustomerId;

async function openPolicy(
  policyId: PolicyId
): Promise<void> {
  // ...
}

await openPolicy(customerId);

This time the browser never starts. There is no navigation, API request, timeout, screenshot, or trace to investigate. The type checker stops us first, and that is a very different kind of quality engineering. We didn't move the failure earlier:

We removed that particular invalid operation from the set of type-correct programs.

That is shift left taken seriously.

Don't test what the compiler can prove

This gives us a useful rule:

Don't test what the compiler can prove.

Not because compilers are better than tests, but because they answer different questions.

TypeScript can answer:

Can a CustomerId be supplied where a PolicyId is required?

Playwright can answer:

Can this customer actually open this policy through the running application?

Those are fundamentally different questions. One concerns the validity of our program; the other concerns the behaviour of our system. Using an E2E test to discover the first is expensive, while using TypeScript to prove the second is impossible. So maybe we've been describing shift left too loosely.

Shift left isn't:

Move as much testing left as possible.

A better definition is:

Move every guarantee to the earliest layer capable of establishing it.

That changes the architecture.


Validate at the boundary

Types don't make reality disappear

Before we get carried away, there is a catch.

This still compiles:

const policyId = "banana" as PolicyId;

Branded types are not runtime validation. as PolicyId does not mean:

This is definitely a valid policy ID.

It means:

Trust me.

And "trust me" is not much of a quality strategy. Somewhere, strings still enter our system: from an API, a fixture, an environment variable, a database, or generated test data. At those boundaries we still need runtime validation.

The architecture becomes:

external value
      │
      ▼
   validate
      │
      ▼
    parse
      │
      ▼
   PolicyId
      │
      ▼
trusted framework

The important idea isn't that casts disappear. It's that trust has a boundary: validate once, establish the domain invariant, and then preserve it.

And a valid ID still doesn't mean the policy exists

This distinction matters. A PolicyId might establish:

This value has the representation required of a policy identifier.

It does not establish:

This policy exists.

And definitely not:

This policy belongs to this customer.

Or:

This customer is authorized to cancel it.

Those are facts about the running system. Types don't eliminate testing; they eliminate uncertainty about the things types can actually express. And that is precisely why Playwright becomes more valuable.


Spend browsers on reality

Stop wasting browsers on cheap mistakes

Consider the original failure:

CustomerId passed as PolicyId
        ↓
Chromium starts
        ↓
application loads
        ↓
API request
        ↓
404
        ↓
UI never reaches expected state
        ↓
Playwright timeout

That timeout is almost useless. It is merely the last visible symptom of a mistake that happened much earlier. Remove that entire class of mistake before runtime and Playwright is left with the interesting questions: Can the customer authenticate and find the policy? Does the backend return the correct data? Is authorization applied correctly? Can the policy be cancelled, and does the UI respond as expected?

Those questions are expensive.

But they deserve to be expensive.

They require a running system, which leads to the second rule:

Don't use Playwright to discover what TypeScript already knows. Use Playwright to discover what only reality can tell you.

Playwright isn't just a bug detector

This is where I think our mental model of E2E testing needs to change.

We often think:

Playwright
    =
find bugs

I'd rather think:

Playwright
    =
collect behavioural evidence

A Playwright test tells us something very specific: under these conditions, this running application behaved as expected through a real browser. That's expensive evidence, but it's also rich evidence, and it belongs in an evidence stack:

CustomerId ≠ PolicyId
        ↓
Type system

External value conforms to our PolicyId contract
        ↓
Runtime validation

PolicyId resolves through the service
        ↓
API / integration

User can find and interact with the policy
        ↓
Playwright

System survives contact with the real world
        ↓
Production observability

None of these layers replaces the others. They remove different kinds of uncertainty.

Shift left doesn't mean "everything to the left"

This is where shift-left conversations sometimes go wrong. Earlier isn't automatically better. A compiler is incredibly fast because the questions it can answer are constrained; a browser is expensive because the world it observes is much richer.

You cannot shift this:

Can a customer successfully complete the cancellation journey?

into a branded type, and you shouldn't even try.

Instead:

Domain distinctions
        ↓
Type system

External data
        ↓
Runtime validation

System contracts
        ↓
API / integration

User behaviour
        ↓
Playwright

Operational reality
        ↓
Observability

That's not a testing pyramid. It's an evidence architecture. The goal isn't to maximize tests, but to ask each layer only the questions that layer is good at answering.


Design the framework as a constrained API

Your Playwright framework is an API

This becomes especially important when a test suite grows. A mature Playwright framework isn't just a folder full of .spec.ts files; it becomes an API for interacting with your system:

loginAs(user);
findCustomer(customerId);
openPolicy(policyId);
submitClaim(claim);
cancelPolicy(policy);

If everything accepts strings:

openPolicy("12345");
findCustomer("12345");
submitClaim("12345");

then the real domain model lives in people's heads. Humans need to remember it, documentation needs to explain it, reviewers need to catch violations, and increasingly:

AI agents need to guess it.

That's where this becomes much bigger than TypeScript ergonomics.

AI changes the economics of constraints

Consider this API:

cancelPolicy(id: string);

A developer has enormous freedom. So does a coding agent. Almost any string is a valid program. Now compare:

cancelPolicy(policy: ActivePolicy);

Something important has changed. The agent isn't smarter and hasn't suddenly learned insurance. It can still make mistakes, but the number of type-correct mistakes available to it has become smaller.

We constrained the solution space.

That matters enormously in agentic development. For humans, strong types provide feedback; for agents, strong types also define boundaries.

Guardrails beat instructions

Imagine putting this in an agent instruction file:

When opening a policy, always use a PolicyId. Never accidentally use a CustomerId.

Fine. But why should correctness depend on the agent remembering that sentence?

If the rule can be encoded as:

function openPolicy(
  policyId: PolicyId
): Promise<void>

then the environment enforces it every time. Humans don't have to remember, agents don't have to remember, and code review doesn't have to rediscover it. The compiler doesn't get tired. That gives us a broader principle:

If a rule can become a guardrail, stop leaving it as an instruction.

Prompts express intent. Types constrain possibilities. Validation establishes trust. Tests gather runtime evidence. Different jobs, different layers.

Now imagine autonomous testing

Take this one step further. An AI agent generates tests, another generates test data, another analyzes failures, and another proposes fixes. Maybe eventually much of that loop runs without a human approving every individual step. Now ask:

What kind of environment do we want those agents operating in?

One that says:

Here are some functions. Here's some documentation. Please be careful.

Or one that says:

Here are the valid domain concepts. Here are the operations available to them. Invalid combinations don't pass the quality gate.

That's a profound architectural difference. We're no longer relying exclusively on agent intelligence:

We're engineering the environment in which that intelligence operates.

That, I suspect, will become one of the defining principles of AI-native software engineering.


Make failures more meaningful

The best failed test may be the one that never ran

Suppose an agent generates 100 Playwright tests. We could launch 100 browsers and let runtime discover every mistake. Or, before Chromium starts, we could eliminate classes of invalid programs: wrong identifiers, invalid structural combinations, malformed boundary data, impossible API calls, and broken type contracts.

Now Playwright receives fewer meaningless executions, and something interesting happens to the failures that remain. A Playwright failure increasingly means:

This survived our structural guardrails and still failed when it met reality.

That's a much more interesting, valuable, and usually more diagnosable failure.


One class of failure at a time

Software failure will never become impossible. That's not the point. Instead, take every potential failure and ask:

What's the earliest layer capable of ruling this out?

Domain identity? Put it in the type system. External input? Validate it at the boundary. System contract? Test the integration. User behaviour? Run Playwright. Production-only uncertainty? Observe production. Don't move everything left.

Move the right things left.

Because every time we transform:

something we eventually detect

into:

something our architecture doesn't permit

we remove one possible failure from the system. Playwright doesn't become less valuable when we do that; it becomes more focused, more meaningful, and more expensive for the right reasons. So perhaps the mature endpoint of shift left isn't more testing at all.

It's better allocation of evidence.

The compiler doesn't replace Playwright; it protects Playwright from questions that never deserved a browser. And Playwright doesn't prove our types are correct; it takes over where static guarantees end and reality begins. That's the boundary worth designing for.

Don't use Playwright to discover what TypeScript already knows.

Use Playwright to discover what only reality can tell you.

And perhaps the most important principle for the age of AI:

Don't just teach machines how to avoid mistakes.

Build systems in which entire classes of mistakes are no longer valid moves.

Shift left until failure becomes impossible—one class of failure at a time.

Sources