TL;DR
- Lambda layers mount at
/opt, a path that exists nowhere on your machine. Import from it and your type checker fails, your editor complains, and your tests can't resolve it.- The usual fixes — a wrapper module, a conditional require, publishing the shared code to a registry as well — all push an infrastructure concern into application source.
- Two lines of configuration solve it instead. Map the runtime path to a local directory in your compiler config; mark the same path external in your bundler config. The same import string then type-checks locally and resolves from the mount in production.
- The bundler line is the one people miss, and missing it fails silently: the bundler inlines the layer's code into every function, everything still works, and the layer becomes dead weight.
- So: if a build optimisation can silently not happen, add a check that it happened. Assert on the built artefact in CI.
- The general principle — when one identifier must mean different things in different build phases, configure each phase's resolution rather than making the code conditional.
Lambda layers mount at /opt. Node code inside one lands at /opt/nodejs, so a function importing shared code writes something like:
import { createResponse } from '/opt/nodejs/shared/response.js';That path exists nowhere except inside a running Lambda execution environment. Not on your laptop, not in your test runner, not in CI. So the moment you write that line, your editor underlines it, your type checker fails, and your tests can't resolve the module.
Everyone hits this. The standard responses are all slightly bad.
A wrapper module that re-exports from the layer path in production and a local path otherwise. Now every shared module has a shim, and the shim is a place bugs live.
A conditional require keyed on an environment variable. Same problem, and now the type checker can't follow it either.
Publish the shared code to a private registry and install it as a dependency too, so it exists in both places. Two sources of truth and a version-skew problem between the installed copy and the mounted one.
Give up on layers. Genuinely defensible — bundlers have got good enough that per-function bundling is a reasonable default. But if you've decided layers are worth it, there's a smaller answer.
Two lines
The first goes in your TypeScript config:
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"/opt/nodejs/*": ["./layers/shared/*"]
}
}
}That tells the type checker: when you see an import from the layer mount path, resolve it against this local directory. Types work, go-to-definition works, the editor goes quiet. Nothing is generated or copied — it's a resolution rule that exists only at compile time.
The second goes in your build configuration:
Metadata:
BuildMethod: esbuild
BuildProperties:
Format: esm
Target: es2022
External:
- "/opt/nodejs/*"That tells the bundler: when you see this path, leave it alone. Don't resolve it, don't inline it, emit it exactly as written.
So the same literal string is understood three different ways, correctly, by three different tools. The type checker treats it as a local directory. The bundler passes it through untouched. The runtime resolves it against the actual mount.
One import statement. No shim, no conditional, no second copy of anything.
flowchart LR
SRC["import from<br/>/opt/nodejs/shared"]
SRC --> TC[Type checker]
SRC --> BD[Bundler]
SRC --> RT[Lambda runtime]
TC -->|paths mapping| L1[./layers/shared<br/>types resolve]
BD -->|marked external| L2[emitted unchanged<br/>not inlined]
RT -->|layer mounted at /opt| L3[real module loaded]The second line is the one that matters
The path mapping is a reasonably well-known trick. The External declaration is the part people leave out, and leaving it out fails in the worst possible way.
Without it, the bundler follows the same resolution rule — sees the import, resolves it via the mapping to your local directory, and inlines the layer's code into every function's bundle.
Everything still works. Tests pass. Deploys succeed. Functions respond correctly. You have simply, silently, defeated the entire point of having a layer: each function now carries its own copy of the shared code, the mounted layer is dead weight, and publishing a new layer version changes nothing because nothing is reading it.
There is no error. No warning. The only way to detect it is to look at what the build actually produced — and the build output is the least observable artefact in a serverless deployment. It's generated in CI, consumed by an upload step, and excluded from source control.
Which is the genuinely reusable lesson here, and it's bigger than layers:
If a build optimisation can silently not happen, add a check that it happened.
A bundle that inlined code it should have left external is indistinguishable from a correct one, from every angle except the file nobody opens. So open it, in CI:
# after sam build
BUNDLE=.aws-sam/build/MyFunction/app.mjs
# Layer imports must survive as bare specifiers.
# `from ?"` matches both minified and unminified output — SAM's esbuild
# defaults Minify to true, so a space-less pattern works today and breaks
# the day someone turns minification off to debug something.
grep -qE 'from ?"/opt/nodejs' "$BUNDLE" \
|| { echo "FAIL: layer imports were inlined into the bundle"; exit 1; }
# A symbol that only exists inside the layer must NOT be present.
grep -q 'INTERNAL_LAYER_MARKER' "$BUNDLE" \
&& { echo "FAIL: layer source found inside the bundle"; exit 1; }The second check needs something to look for, and it won't have one unless you put it there. Export a sentinel from the layer and never import it:
// layers/shared/marker.js — exists solely to be absent from function bundles
export const INTERNAL_LAYER_MARKER = 'INTERNAL_LAYER_MARKER';Skip that and the grep matches nothing, the check passes, and you have a green build asserting a property nobody verified — which is the failure this whole section is about, reproduced inside the thing meant to catch it. Worth planting the marker deliberately rather than hoping some distinctive string in the layer stays distinctive.
Crude, a handful of lines, runs every build, cannot go stale. That's the version that works.
The version that doesn't work — and I've seen this instinct, it's a good instinct badly executed — is committing a copy of the build output to the repository so it's reviewable in a diff. It answers the right question for about a fortnight, then drifts from the real build and becomes a confusing liability that looks authoritative.
The contract nothing checks
Now the uncomfortable part, because this technique hides a seam so well that it's easy to forget the seam exists.
Your types come from the source tree at HEAD. The mounted layer is whatever version was published and pinned. Nothing verifies that those two agree.
If they've diverged — a function renamed, a parameter added, a module moved — the type checker will confirm code that fails at runtime with a module-not-found, or worse, calls something with the wrong signature and fails on a code path nobody exercises until Thursday.
Every layer setup has this problem. The path mapping doesn't create it. But the mapping makes the import feel local, and it isn't. It's a call into a separately deployed artefact that happens to use import syntax.
Partial mitigations, in the order I'd apply them:
Keep the layer's public surface small. A layer exporting three functions has a contract you can hold in your head. A layer exporting forty has one nobody has ever read in full. This is the highest-leverage thing on the list.
Publish the layer and update the pins in the same change, so source and mount move together by default and divergence is deliberate.
Treat any change to an exported signature as a breaking change requiring a coordinated rollout. Because it is one.
Consider generating the external list rather than hand-writing it. It's per-function configuration that must stay in sync with the layer's actual contents. A function that fails to mark a layer path external silently inlines it; one that marks something external that isn't in the layer fails at runtime. Copying these lists between functions is exactly how they drift.
What generalises
The specific technique is useful if you write Lambdas against layers. The principle underneath is broader:
When one identifier has to mean different things in different phases of the build, make each phase's resolution explicit — don't make the identifier conditional.
The bad solutions all work by making the code aware of its environment: a shim, a conditional, a flag. That drags an infrastructure concern into application source, where it doesn't belong and where the next person will copy it into a new module without knowing why it's there.
The good solution leaves the source stating one honest fact — "this comes from the layer" — and configures each tool to resolve that fact appropriately. Type checker gets a mapping. Bundler gets an exclusion. Runtime gets a mount. The source file knows about none of it.
That's the same instinct as path aliases in a monorepo, symbol replacement in a linker, and dependency injection in an application. The code declares what it needs; something else decides where that comes from. It works at the scale of an import statement for exactly the same reason it works at the scale of a service.