
AbstractTextFileService takes eighteen constructor parameters. Not eighteen arguments of which some are options objects — eighteen separate injected services, each one a decorated parameter on its own line, in src/vs/workbench/services/textfile/browser/textFileService.ts. Its two concrete subclasses then restate all eighteen, verbatim, to pass them up to super(). Fifty-four parameter declarations for one service. That is what VS Code dependency injection looks like at full extension, in microsoft/vscode at version 1.138.0, read on 2026-09-04.
The interesting question is not whether that is a lot. It is why a team that built a custom lint rule to protect a single line of type-system plumbing looked at eighteen parameters and did not split the class. And why, with InversifyJS and tsyringe and Angular’s injector all available and all mature, they wrote the container themselves — 840 lines across seven files in src/vs/platform/instantiation/common/, licence headers and comments included.
The answer is mostly about startup time, partly about what TypeScript’s type system can be made to verify, and partly about a bet on an unfinished language feature that has since stopped paying out.
The Container Fits in One File

The public surface is 188 lines across three files. Everything a consuming class needs lives in instantiation.ts, 130 lines, whose load-bearing export is a single function:
export function createDecorator<T>(serviceId: string): ServiceIdentifier<T>Its doc comment reads, in full: “The \only\ valid way to create a {{ServiceIdentifier}}.” The value it returns does two jobs at once. It is the token you import when you want to talk about a service, and it is the parameter decorator you attach to a constructor argument to ask for one:
export const IFileService = createDecorator<IFileService>('fileService'); class Thing { constructor(@IFileService private readonly fileService: IFileService) { }}There is no separate container-configuration module mapping string tokens to implementations. The file that declares the contract also exports the token, so the two cannot drift apart or be imported independently of each other.
The registration mechanism underneath is deliberately unglamorous. createDecorator returns a function that, when applied, calls storeServiceDependency, which writes to two string-keyed properties on the constructor function itself:
export const DI_TARGET = '$di$target';export const DI_DEPENDENCIES = '$di$dependencies';That detail is worth pausing on, because it is the whole reason no reflection library is needed. There is no Reflect.getMetadata call, and neither reflect-metadata nor emitDecoratorMetadata appears anywhere in the repository’s package.json or src/tsconfig.base.json. The dependency list is an ordinary array hanging off an ordinary property of an ordinary function object, and createInstance reads it back with ctor[DI_DEPENDENCIES] || [], sorts by parameter index, resolves each identifier, and finishes with Reflect.construct(ctor, args.concat(serviceArgs)). Services always land last, positionally.
The decorator also refuses to be used anywhere else. If it is invoked with anything other than the three arguments a parameter decorator receives, it throws '@IServiceName-decorator can only be used to decorate a parameter'.
_serviceBrand Is How the Type System Counts Your Arguments

Every service interface in the codebase carries a line that looks like dead weight:
readonly _serviceBrand: undefined;The microsoft/vscode wiki’s Source Code Organization page explains the first half of why: “A service definition is two parts: (1) the interface of a service, and (2) a service identifier – the latter is required because TypeScript doesn’t use nominal but structural typing. A service identifier is a decoration (as proposed for ES7) and should have the same name as the service interface.”
Structural typing means two interfaces with the same shape are the same type. For a service registry keyed on identity, that is exactly wrong, and _serviceBrand makes every service interface structurally distinct from every other one whether or not their members happen to line up.
But the brand earns its keep a second time, in a way the wiki does not mention. instantiation.ts declares:
export type BrandedService = { _serviceBrand: undefined }; export type GetLeadingNonServiceArgs<TArgs extends any[]> = TArgs extends [] ? [] : TArgs extends [...infer TFirst, BrandedService] ? GetLeadingNonServiceArgs<TFirst> : TArgs;That recursive conditional type peels trailing parameters off a constructor signature for as long as they are assignable to BrandedService, and hands back only the leading ones. It is what makes createInstance(Ctor, ...args) type-check against the non-injected arguments alone. Without a marker member, the compiler would have no way to tell a service parameter from an ordinary one, and every call site would have to pass all of them or none. The brand is not documentation and it is not defensive style. It is the discriminator a type-level argument split needs to exist at all.
Which is why it gets its own enforcement. .eslint-plugin-local/code-declare-service-brand.ts is a custom rule whose entire body matches PropertyDefinition[key.name="_serviceBrand"][value] and reports The '_serviceBrand'-property should not have a value, auto-fixing to declare _serviceBrand: undefined;. A brand with a value would be emitted as a real field and initialised at runtime on every instance, for a member that exists only for the compiler. The rule is configured at severity warn in eslint.config.js, which sounds advisory until you read build/eslint.ts, where any nonzero warning-or-error total throws. Layering is enforced through the same mechanism, and has its own article; the point here is that a single line of type plumbing across a hundred-plus service modules was considered worth a purpose-built linter.
Eighteen Services, Declared Three Times

Here is the constructor that the whole apparatus makes possible, and the one that is hardest to defend:
| # | Injected service | What it is for |
|---|---|---|
| 1 | IFileService | reading and writing files |
| 2 | IUntitledTextEditorService | unsaved buffers |
| 3 | ILifecycleService | shutdown and restore phases |
| 4 | IInstantiationService | creating more objects |
| 5 | IModelService | text models |
| 6 | IWorkbenchEnvironmentService | environment configuration |
| 7 | IDialogService | modal prompts |
| 8 | IFileDialogService | open and save pickers |
| 9 | ITextResourceConfigurationService | per-resource settings |
| 10 | IFilesConfigurationService | file-specific settings |
| 11 | ICodeEditorService | the open editors |
| 12 | IPathService | path semantics |
| 13 | IWorkingCopyFileService | coordinated file operations |
| 14 | IUriIdentityService | URI comparison |
| 15 | ILanguageService | language identification |
| 16 | ILogService | logging |
| 17 | IElevatedFileService | writes needing elevation |
| 18 | IDecorationsService | explorer decorations |
Read against the single-responsibility rule most of us were taught, that is a class with eighteen reasons to change, and no amount of tooling makes it fewer. A reader trying to work out what AbstractTextFileService is actually for has to get past eighteen collaborators first. It is a genuine cost and the codebase does not hide it.
What the container removes, and what it does not
What the container removes is narrower than it first appears. It removes manual wiring — nobody anywhere calls new AbstractTextFileService(...) with eighteen positional arguments — and it makes every collaborator a narrow interface rather than a concrete class, so each one is independently substitutable in a test. Those are real. What it does not remove is the cognitive surface, and it does not even remove all of the mechanical cost: because the dependency metadata lives on the constructor function and storeServiceDependency starts a fresh list whenever the decorated target differs from the one already recorded, a subclass cannot inherit its parent’s injection list by declaring nothing. So BrowserTextFileService and NativeTextFileService each restate all eighteen parameters in full, changing exactly one of them — INativeWorkbenchEnvironmentService in place of IWorkbenchEnvironmentService — to pass them through to super().
Fifty-four declarations, three files, one behavioural difference. Dependency injection made that survivable. It did not make it free, and treating a container as licence to keep adding parameters is the failure mode a smaller team should be most careful about importing along with the pattern.
Delayed Instantiation Is a Proxy Over an Idle Value

descriptors.ts is twenty-one lines and defines one class:
export class SyncDescriptor<T> { readonly ctor: any; readonly staticArguments: unknown[]; readonly supportsDelayedInstantiation: boolean; // ...}supportsDelayedInstantiation defaults to false, and the enum that sets it — InstantiationType in extensions.ts — carries the team’s own preference in its doc comments. Eager is described as “Instantiate this service as soon as a consumer depends on it. \_Note\_ that this is more costly as some upfront work is done that is likely not needed.” Delayed gets “Instantiate this service as soon as a consumer uses it. This is the \_better\_ way of registering a service.”
That is a startup-performance decision written into the container’s vocabulary. src/vs/platform contains 107 service-domain subdirectories as of 2026-09-04 — accessibility, chat, debug, files, mcp, terminal, agentHost, and a hundred more. Constructing all of them before the window paints is not an option, so laziness could not be a pattern applied on top of a general container. It had to be something the container understood natively.
The proxy trick
The implementation, in instantiationService.ts, is more interesting than a lazy getter. When a descriptor supports delayed instantiation, the container returns a Proxy over Object.create(null) backed by a GlobalIdleValue. The comment above it says exactly what it is for: “Return a proxy object that’s backed by an idle value. That strategy is to instantiate services in our idle time or when actually needed but not when injected into a consumer.”
Consumers receive the proxy immediately. Property access forces construction, then caches and binds the resulting method on the proxy target so subsequent calls skip the trap. getPrototypeOf returns ctor.prototype, so instanceof still answers correctly against something that may not exist yet.
The sharpest part is the event handling. A consumer that only subscribes to a service’s events would otherwise force construction just by wiring itself up, defeating the whole mechanism. So the trap intercepts any property name starting with onDid or onWill — the codebase’s naming convention for events — and returns a stand-in Event that records the listener in a linked list. The source comment is “return \”empty events\” when the service isn’t instantiated yet.” When the idle value finally resolves, every recorded listener is replayed onto the real emitter and the queue is cleared. Subscribing to a service is free until something actually calls a method on it.
What a General-Purpose Container Would Have Charged
Nothing in the source states the reasoning, so this is read off the mechanism rather than quoted from a maintainer. Three properties fall out of the design that a mainstream library would have made harder to obtain:
| Property | How VS Code gets it | What a reflection-based container typically requires |
|---|---|---|
| No runtime reflection | metadata written to $di$dependencies on the constructor | reflect-metadata at the entry point plus emitDecoratorMetadata |
| Lazy construction as a first-class concept | supportsDelayedInstantiation on the descriptor the container already understands | a lazy-provider or factory wrapper applied per registration |
| Token and contract in one place | export const IFileService = createDecorator(...) beside the interface | a separate symbol table or module mapping tokens to implementations |
The scale is what justifies it. A hundred-plus services, a hard startup budget, and a bundle that does not want a reflection polyfill in it are the specific conditions that make writing 840 lines cheaper than adopting someone else’s. At a dozen services with no startup pressure, that arithmetic inverts and a library is the better trade.
The Sharp Edges the Container Does Not Hide
Reading the implementation rather than the interface turns up three places where the hand-rolled container is noticeably less forgiving than a mature framework, all in instantiationService.ts at 1.138.0.
Cycle detection is a counter. The graph walk carries the comment “a weak but working heuristic for cycle checks” and throws CyclicDependencyError once its loop counter passes 1,000. The error’s message calls graph.findCycleSlow(), and if that finds nothing it falls back to dumping the entire graph.
Missing services are not an error by default. InstantiationService‘s constructor takes private readonly _strict: boolean = false. Off strict, _throwIfStrict only warns, so a class depending on a service nobody registered receives undefined in that position and finds out later.
Static arguments are silently reshaped. If the leading non-service arguments passed to createInstance do not line up with the position of the first decorated parameter, the container writes a console.trace — “First service dependency of … conflicts with … static arguments” — then pads the array with holes or truncates it, and constructs anyway.
These are all defensible in a codebase whose own CI lints it and whose contributors are the same people who wrote the container. They are also exactly the affordances a general-purpose library spends its complexity budget on, which is part of the trade being made.
experimentalDecorators: true Is a Door That Has Since Closed

src/tsconfig.base.json is a short file and one line in it is quietly load-bearing:
{ "strict": true, "experimentalDecorators": true, "target": "ES2024"}The wiki dates the bet without meaning to: a service identifier is “a decoration (as proposed for ES7).” That proposal is not what shipped. TC39’s decorators proposal reached Stage 3 covering classes, methods, accessors and fields — and its README states plainly: “This proposal does not include parameter decorators, but they may be provided by future built-in decorators.”
Microsoft’s own TypeScript 5.0 release notes say the same thing from the implementer’s side: “This new decorators proposal is not compatible with --emitDecoratorMetadata, and it does not allow decorating parameters. Future ECMAScript proposals may be able to help bridge that gap.”
So the entire @IFileService-on-a-constructor-parameter convention — the thing every stateful class in the repository with more than zero dependencies uses — has no expression in standard decorators. VS Code cannot turn experimentalDecorators off. Not because migrating would be tedious, but because the feature the design rests on is not in the standard that replaced the proposal it was built against.
The door is not bolted, only shut
TC39’s EXTENSIONS.md lists parameter decorators as a follow-on and says “The main open question is whether to include parameter decorators initially, as they have attracted widespread interest and uptake in TypeScript within classes” — and the flag itself is not going anywhere, since the 5.0 notes commit to --experimentalDecorators continuing to exist “for the foreseeable future.” But as of 2026-09-04 there is no standard-decorator path for this DI system that does not involve redesigning how a constructor declares what it needs.
That is what lock-in looks like when it is nobody’s fault. The decision was correct when it was made, against the best available information, in a language feature that had not yet settled. It was also isolated about as well as it could be — the commitment is expressed through one function and one naming convention rather than scattered across the codebase — which is the part worth copying. Bet on unfinished standards if the payoff justifies it, and put the bet behind a seam.
❓ Frequently Asked Questions
Is VS Code’s DI container available as a package?
No. It lives at src/vs/platform/instantiation/common/ inside microsoft/vscode and is not published to npm separately. The repository is MIT-licensed as Code - OSS, so the code can be read and reused under those terms — but the product you download from Microsoft ships under a different, proprietary licence, and those two artifacts are worth keeping distinct whenever “VS Code is open source” comes up.
Why not just use InversifyJS or tsyringe?
The three properties in the table above — no reflection dependency, container-native delayed instantiation, and the token living beside the contract — are what the hand-rolled version buys. None of them matter much at a dozen services. All of them matter at 107 platform service domains with a startup budget the team publishes numbers against. The honest reading is that the scale justified it here and very likely does not justify it in your codebase.
Does _serviceBrand do anything at runtime?
Nothing at all, and a custom ESLint rule exists to keep it that way by rejecting any _serviceBrand declaration that carries a value. It is a compile-time discriminator for two jobs: making service interfaces nominally distinct, and letting GetLeadingNonServiceArgs separate injected parameters from ordinary ones in a constructor signature.
Is the 18-parameter constructor a bug?
It is a cost that was accepted rather than a mistake that went unnoticed — and it recurs, because each subclass must restate the full list. The defensible version of the argument is that eighteen narrow, individually mockable interfaces behind an invisible wiring layer is a better position than eighteen concrete collaborators reached for directly. The indefensible version is that having a container makes the number stop mattering. It does not.
Can I copy this pattern into a TypeScript project today?
Only with experimentalDecorators enabled, because standard TC39 decorators do not support parameter decorators as of 2026-09-04. That is a real constraint on new code, not just on VS Code’s existing code, and it is the first thing to check before adopting decorator-based constructor injection in anything you expect to maintain for a decade.
Where does the laziness actually show up for a user?
In what happens before the window paints. A service registered with InstantiationType.Delayed hands consumers a proxy rather than an instance, and construction is deferred to idle time or to the first real method call — so subscribing to a service’s events costs nothing until something uses it. Multiplied across a hundred-odd service domains, that is the difference between a startup sequence that constructs the application and one that constructs only the part of it you are about to look at.
📦 Recommended Resources
Some links below are Amazon affiliate links. If you buy through them, this site earns a small commission at no extra cost to you.
Read the source before you read anything else, because on this particular subject it is short. The whole container is src/vs/platform/instantiation/common/ in the microsoft/vscode repository — seven files, 840 lines, of which instantiation.ts (130) and descriptors.ts (21) contain everything a consumer touches. Add .eslint-plugin-local/code-declare-service-brand.ts and the Dependency Injection section of the Source Code Organization wiki page and you have the complete design in under an hour. The books below are for the decisions the source cannot make for you.
- Dependency Injection Principles, Practices, and Patterns by Steven van Deursen and Mark Seemann (Manning, 2019) — the reference text for the question this article only gestures at: which container behaviours are essential and which are ceremony. Its treatment of composition roots, object lifetimes and the anti-patterns that wide constructors slide into is the vocabulary you want before deciding whether eighteen injected services is a smell or a shape. The examples are .NET; the reasoning is not.
- Effective TypeScript, 2nd Edition by Dan Vanderkam (O’Reilly, 2024) — the practical companion to the
_serviceBrandmaterial here. It covers structural typing and branded types directly, along with the conditional-type and variadic-tuple machinery thatGetLeadingNonServiceArgsis built from. If the recursive type that peels service parameters off a constructor signature read as magic, this is the book that turns it into a technique you can use.
- Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides (Addison-Wesley, 1995) — worth listing here for a specific reason rather than as a classic. Erich Gamma, its first-named author, went on to lead the team that built this editor, and the registry-and-identifier shape running through
src/vs/platformis recognisably the same thinking three decades later. Read it as the intellectual lineage of the codebase, not as a catalogue to apply.
✅ Pro tip: before adopting decorator-based constructor injection in a new project, check one thing first — whether you can live with experimentalDecorators indefinitely. TC39’s standard decorators do not cover parameters, so that flag is not a migration you defer. It is the design.