Design: Project tspconfig.yaml
Status
Draft — Seeking feedback on project boundary semantics and open questions.
Related: PR #9886 — Feature Flags via Directives (Alternative D)
Problem
Today, tspconfig.yaml serves as a build/output configuration — it configures linter rules, emit targets, and output directories. It does not define a project boundary. This creates several problems:
- No project identity: A single TypeSpec project may use multiple tspconfig files (one per emit target), or share a config across projects. There is no authoritative "this is a TypeSpec project" marker.
- Entrypoint ambiguity: The IDE walks up directories looking for
package.json with exports["typespec"] / tspMain (legacy) or main.tsp. There's no way to explicitly declare the entrypoint in config.
- No project-scoped settings: Settings like feature flags (PR #9886) need a project-level scope. Today's tspconfig can't serve this role because it's build config, not project config.
- Monorepo pain: In repositories with multiple TypeSpec services, there's no clean way to define boundaries between projects. This also causes issues in the Language server to figure out which files belong to which project.
Goals
- Define a project boundary mechanism using
tspconfig.yaml
- Allow explicit entrypoint declaration
- Establish a foundation for project-scoped settings (feature flags, etc.)
- Maintain full backward compatibility
Non-goals (for this proposal)
- Workspace-level configuration (listing all projects in a monorepo)
- Project references (cross-project dependency declarations)
- Feature flag implementation details (covered in PR #9886)
Design
1. Project marker
A tspconfig.yaml becomes a project boundary when it contains a project marker. The directory containing this file becomes the project root. When no project marker is present, the tspconfig is a regular build configuration (backward compatible).
Decision needed: Which syntax should mark a config as a project?
Option A: project field
A top-level project field serves as both the marker and the namespace for project settings:
# Full form
project:
entrypoint: main.tsp
# Shorthand (all defaults)
project: true
- Pro: Project settings (entrypoint, future feature flags) are grouped under one key — clean namespace separation from build config.
- Pro: Extensible — adding
features, references, etc. as nested properties is natural.
- Con:
project: true (marker) vs project: { ... } (config object) is a dual type that can feel inconsistent.
Option B: kind field
A top-level kind discriminator marks the config type, with project settings at the top level:
kind: project
entrypoint: main.tsp
- Pro: Naturally extensible to other config kinds in the future (e.g.,
kind: workspace).
- Pro: Flat top-level structure feels simpler for the common case.
- Con: Project-scoped settings (entrypoint, future feature flags) live at the top level alongside build config fields — no clear separation.
2. Entrypoint resolution
The project.entrypoint field declares the main file for the project, relative to the project root:
# Default — looks for main.tsp in project root
project:
entrypoint: main.tsp
# Explicit — custom path
project:
entrypoint: src/service.tsp
IDE behavior change
Current behavior (packages/compiler/src/server/entrypoint-resolver.ts):
- Walk up from current file's directory
- Check
package.json for exports["typespec"] or tspMain (legacy)
- Look for
main.tsp
- Repeat in parent directories until filesystem root
- Fall back to the file itself
New behavior:
- Walk up from current file's directory
- Check for project tspconfig.yaml — if found, use
project.entrypoint (default: main.tsp)
- Check
package.json for exports["typespec"] or tspMain (legacy)
- Look for
main.tsp
- Repeat in parent directories until filesystem root
- Fall back to the file itself
The project tspconfig check is inserted as the highest priority resolution step at each directory level. If a project tspconfig is found, the walk stops — the entrypoint is definitively resolved.
CLI behavior change
Current behavior (packages/compiler/src/core/entrypoint-resolution.ts):
When tsp compile <dir> receives a directory:
- Check
package.json for exports["typespec"] or tspMain (legacy)
- Fall back to
main.tsp
New behavior:
- Check for project tspconfig.yaml — if found, use
project.entrypoint
- Check
package.json for exports["typespec"] or tspMain (legacy)
- Fall back to
main.tsp
Note: The CLI does not walk up directories (unlike the IDE). This is unchanged.
3. Project boundary semantics
A project boundary defines the scope of project-level settings. Key rules:
- A project includes all TypeSpec files under its root directory, excluding files that fall under a nested project root (those belong to the nested project).
- When the compiler loads a library (e.g., from
node_modules), it crosses into that library's project boundary. The library's own project tspconfig (if any) takes effect for its files.
- Project-level settings (like feature flags) do not propagate across project boundaries.
Example: Monorepo layout
repo/
├── tspconfig.yaml # project: { entrypoint: main.tsp } ← Project A
├── main.tsp
├── models/
│ └── widget.tsp # part of Project A
├── services/
│ └── orders/
│ ├── tspconfig.yaml # project: { entrypoint: main.tsp } ← Project B
│ └── main.tsp
└── node_modules/
└── @typespec/http/
├── package.json # exports: { "typespec": "./lib/main.tsp" }
├── tspconfig.yaml # project: { features: { ... } } ← Library project
└── lib/
└── main.tsp
widget.tsp belongs to Project A
services/orders/main.tsp belongs to Project B (nested project boundary)
@typespec/http has its own project boundary as a library
4. Relationship between project tspconfig and build tspconfig
The project tspconfig is also a build config. All existing fields (emit, options, linter, etc.) continue to work alongside the project field:
project:
entrypoint: main.tsp
emit:
- "@typespec/openapi3"
options:
"@typespec/openapi3":
emitter-output-dir: "{project-root}/out"
linter:
extends:
- "@typespec/http/all"
Multiple build configurations for the same project
To support different emit targets for the same project, use additional non-project tspconfig files that extend the project config:
my-service/
├── tspconfig.yaml # project config + default build config
├── tspconfig.openapi.yaml # build-only override (no project field)
├── tspconfig.csharp.yaml # build-only override (no project field)
└── main.tsp
# tspconfig.openapi.yaml
extends: ./tspconfig.yaml # inherits project settings + base config
emit:
- "@typespec/openapi3"
# tspconfig.csharp.yaml
extends: ./tspconfig.yaml
emit:
- "@typespec/http-client-csharp"
Usage:
tsp compile . # uses tspconfig.yaml (project + default build)
tsp compile --config tspconfig.openapi.yaml # uses openapi override
tsp compile --config tspconfig.csharp.yaml # uses csharp override
A non-project config that extends a project config inherits the project boundary — it knows where the project root is and what the entrypoint is.
5. Libraries and project tspconfig
Libraries (reusable TypeSpec packages distributed via npm) can define a project tspconfig. This is important for:
- Scoping future feature flags to the library's own code
- Providing an authoritative project boundary for the library
For libraries, package.json exports["typespec"] (or tspMain for legacy packages) remains the authoritative entrypoint mechanism:
node_modules/@typespec/http/
├── package.json # exports: { "typespec": "./lib/main.tsp" }
├── tspconfig.yaml # project: { features: { ... } }
└── lib/
└── main.tsp
Resolution priority for library entrypoint:
package.json exports["typespec"] (primary, authoritative for libraries)
package.json tspMain (legacy fallback)
- Project tspconfig
project.entrypoint (new, lowest priority for libraries)
Rationale: package.json exports["typespec"] is the current convention for npm packages and must remain authoritative to avoid breaking changes. tspMain is supported as a legacy fallback.
Entrypoint consistency validation
When a library defines both a package.json entrypoint and a project tspconfig with project.entrypoint, these must agree. Rather than maintaining two sources of truth, the exports["typespec"] value could point to the tspconfig.yaml itself, which in turn declares the entrypoint. This way the tspconfig becomes the single authority for the library's TypeSpec configuration.
// @typespec/http package.json
{
"exports": {
".": { "typespec": "./tspconfig.yaml" }
}
}
# @typespec/http tspconfig.yaml
project:
entrypoint: lib/main.tsp
This eliminates the consistency problem — there's only one place declaring the entrypoint — and naturally gives the library a project boundary with all its settings (feature flags, etc.).
If the exports["typespec"] value points to a .tsp file (current behavior) or tspMain is used, it continues to work as before — backward compatible.
Subpath exports and project boundaries
Some libraries expose multiple TypeSpec subpath exports. For example, @typespec/http exports both a root entrypoint and a ./streams subpath.
With the tspconfig-as-export approach, there are two options:
Option A: Single tspconfig, multiple subpath entrypoints listed in package.json
Each subpath export still points to its .tsp file directly. We load a sibling to package.json
{
"exports": {
".": { "typespec": "./main.tsp" },
"./streams": { "typespec": "./lib/streams/main.tsp" }
}
}
- Pro: Simple; one project boundary per npm package; feature flags apply uniformly.
- Con: Subpath exports bypass the tspconfig — they don't get project-level settings unless the compiler infers they belong to the same project boundary.
Option B: Nested tspconfig per subpath
Each subpath export points to its own tspconfig:
{
"exports": {
".": { "typespec": "./tspconfig.yaml" },
"./streams": { "typespec": "./lib/streams/tspconfig.yaml" }
}
}
- Pro: Each subpath has its own project settings; consistent model — every export goes through a tspconfig.
- Con: More files to maintain; unclear if subpaths should really be independent projects — they ship as one npm package.
Leaning toward: Option A for simplicity. Subpath exports are part of the same library and should inherit the root project boundary. The compiler can infer that any .tsp file under the package root (without its own nested tspconfig) belongs to the root project.
6. Feature flags (future extension preview)
With the project boundary established, feature flags (PR #9886, Alternative D) can be scoped to the project:
project:
entrypoint: main.tsp
features:
internal-modifier: on
new-decorators: off
Schema Changes
TypeSpecRawConfig additions
interface TypeSpecRawConfig {
// ... existing fields ...
/** Marks this config as a project boundary */
project?:
| true
| {
/** Main TypeSpec file, relative to config directory. Default: "main.tsp" */
entrypoint?: string;
// Future:
// features?: Record<string, "on" | "off">;
};
}
TypeSpecConfig additions
interface TypeSpecConfig {
// ... existing fields ...
/** Resolved project configuration, undefined if not a project config */
project?: {
/** Resolved absolute path to the entrypoint file */
entrypoint: string;
// Future:
// features: Map<string, boolean>;
};
}
JSON Schema addition
{
"project": {
"oneOf": [
{ "type": "boolean", "const": true },
{
"type": "object",
"properties": {
"entrypoint": {
"type": "string",
"description": "Main TypeSpec file for this project, relative to config directory",
"default": "main.tsp"
}
},
"additionalProperties": false
}
],
"description": "Marks this configuration as a project boundary. When present, the directory containing this file is the project root."
}
}
Open Questions
Q1: Config merging with nested non-project tspconfigs
If a non-project tspconfig exists inside a project boundary (not as a sibling, but in a subdirectory), how should it behave?
my-project/
├── tspconfig.yaml # project config
├── main.tsp
└── subdir/
├── tspconfig.yaml # non-project config — what happens?
└── helper.tsp
Options:
| Option |
Behavior |
Pros |
Cons |
| A) Ignore |
Non-project tspconfigs inside a project are only used when explicitly referenced via --config or extends |
Simple, predictable |
May surprise users who expect config inheritance |
| B) Merge |
Walk up and merge build settings, but project-level settings only come from the project config |
Familiar pattern |
Complex merging rules, potential for confusion |
| C) Warn |
Emit a diagnostic warning about the ambiguous nested config |
Helps users notice misconfiguration |
Could be noisy |
Leaning toward: Option A — non-project tspconfigs inside a project are opt-in via --config or extends. Simplest mental model.
Q2: --config pointing to a project file
Should tsp compile --config path/to/tspconfig.yaml be allowed when the target config is a project file?
Options:
| Option |
Behavior |
| A) Yes, always |
--config works regardless of project field presence |
| B) Yes, with constraints |
Only valid if the compile target is within that project's boundary |
| C) No |
--config can only point to non-project build configs |
Leaning toward: Option B — prevents confusing cross-project compilation while maintaining flexibility.
Implementation Phases
Phase 1: Project boundary + entrypoint
- Add
project field to config schema, JSON schema, and TypeScript types
- Update config loader (
config-loader.ts) to parse and normalize project settings
- Update IDE entrypoint resolver (
entrypoint-resolver.ts) to check project tspconfig first
- Update CLI entrypoint resolution (
entrypoint-resolution.ts) for directories
- Add tests for project config loading, entrypoint resolution, nested projects
- Update
tsp init templates to include project: true
Phase 2: Build config interaction
- Define and implement behavior for
extends with project configs
- Handle
--config flag validation with project boundaries
- Define merging semantics for nested non-project configs
- Update documentation (configuration handbook)
Phase 3: Feature flags
Depends on PR #9886 decisions.
- Add
features field to project config schema
- Implement feature flag scoping within project boundaries
- Implement cross-boundary isolation (features don't propagate into dependencies)
- Compiler API for registering known features and querying enablement at source locations
Migration / Backward Compatibility
- No breaking changes: Existing tspconfig.yaml files without
project continue to work identically.
- Gradual adoption: Projects can add
project: true at their own pace.
- IDE graceful degradation: If no project config is found, fall back to current behavior (walk up looking for
package.json exports["typespec"] / tspMain or main.tsp).
tsp init: Updated templates should include project: true in generated tspconfig.yaml to encourage adoption.
Design: Project tspconfig.yaml
Status
Draft — Seeking feedback on project boundary semantics and open questions.
Related: PR #9886 — Feature Flags via Directives (Alternative D)
Problem
Today,
tspconfig.yamlserves as a build/output configuration — it configures linter rules, emit targets, and output directories. It does not define a project boundary. This creates several problems:package.jsonwithexports["typespec"]/tspMain(legacy) ormain.tsp. There's no way to explicitly declare the entrypoint in config.Goals
tspconfig.yamlNon-goals (for this proposal)
Design
1. Project marker
A tspconfig.yaml becomes a project boundary when it contains a project marker. The directory containing this file becomes the project root. When no project marker is present, the tspconfig is a regular build configuration (backward compatible).
Decision needed: Which syntax should mark a config as a project?
Option A:
projectfieldA top-level
projectfield serves as both the marker and the namespace for project settings:features,references, etc. as nested properties is natural.project: true(marker) vsproject: { ... }(config object) is a dual type that can feel inconsistent.Option B:
kindfieldA top-level
kinddiscriminator marks the config type, with project settings at the top level:kind: workspace).2. Entrypoint resolution
The
project.entrypointfield declares the main file for the project, relative to the project root:IDE behavior change
Current behavior (
packages/compiler/src/server/entrypoint-resolver.ts):package.jsonforexports["typespec"]ortspMain(legacy)main.tspNew behavior:
project.entrypoint(default:main.tsp)package.jsonforexports["typespec"]ortspMain(legacy)main.tspThe project tspconfig check is inserted as the highest priority resolution step at each directory level. If a project tspconfig is found, the walk stops — the entrypoint is definitively resolved.
CLI behavior change
Current behavior (
packages/compiler/src/core/entrypoint-resolution.ts):When
tsp compile <dir>receives a directory:package.jsonforexports["typespec"]ortspMain(legacy)main.tspNew behavior:
project.entrypointpackage.jsonforexports["typespec"]ortspMain(legacy)main.tspNote: The CLI does not walk up directories (unlike the IDE). This is unchanged.
3. Project boundary semantics
A project boundary defines the scope of project-level settings. Key rules:
node_modules), it crosses into that library's project boundary. The library's own project tspconfig (if any) takes effect for its files.Example: Monorepo layout
widget.tspbelongs to Project Aservices/orders/main.tspbelongs to Project B (nested project boundary)@typespec/httphas its own project boundary as a library4. Relationship between project tspconfig and build tspconfig
The project tspconfig is also a build config. All existing fields (
emit,options,linter, etc.) continue to work alongside theprojectfield:Multiple build configurations for the same project
To support different emit targets for the same project, use additional non-project tspconfig files that extend the project config:
Usage:
A non-project config that
extendsa project config inherits the project boundary — it knows where the project root is and what the entrypoint is.5. Libraries and project tspconfig
Libraries (reusable TypeSpec packages distributed via npm) can define a project tspconfig. This is important for:
For libraries,
package.jsonexports["typespec"](ortspMainfor legacy packages) remains the authoritative entrypoint mechanism:Resolution priority for library entrypoint:
package.jsonexports["typespec"](primary, authoritative for libraries)package.jsontspMain(legacy fallback)project.entrypoint(new, lowest priority for libraries)Rationale:
package.jsonexports["typespec"]is the current convention for npm packages and must remain authoritative to avoid breaking changes.tspMainis supported as a legacy fallback.Entrypoint consistency validation
When a library defines both a
package.jsonentrypoint and a project tspconfig withproject.entrypoint, these must agree. Rather than maintaining two sources of truth, theexports["typespec"]value could point to the tspconfig.yaml itself, which in turn declares the entrypoint. This way the tspconfig becomes the single authority for the library's TypeSpec configuration.This eliminates the consistency problem — there's only one place declaring the entrypoint — and naturally gives the library a project boundary with all its settings (feature flags, etc.).
If the
exports["typespec"]value points to a.tspfile (current behavior) ortspMainis used, it continues to work as before — backward compatible.Subpath exports and project boundaries
Some libraries expose multiple TypeSpec subpath exports. For example,
@typespec/httpexports both a root entrypoint and a./streamssubpath.With the tspconfig-as-export approach, there are two options:
Option A: Single tspconfig, multiple subpath entrypoints listed in package.json
Each subpath export still points to its
.tspfile directly. We load a sibling to package.json{ "exports": { ".": { "typespec": "./main.tsp" }, "./streams": { "typespec": "./lib/streams/main.tsp" } } }Option B: Nested tspconfig per subpath
Each subpath export points to its own tspconfig:
{ "exports": { ".": { "typespec": "./tspconfig.yaml" }, "./streams": { "typespec": "./lib/streams/tspconfig.yaml" } } }Leaning toward: Option A for simplicity. Subpath exports are part of the same library and should inherit the root project boundary. The compiler can infer that any
.tspfile under the package root (without its own nested tspconfig) belongs to the root project.6. Feature flags (future extension preview)
With the project boundary established, feature flags (PR #9886, Alternative D) can be scoped to the project:
Schema Changes
TypeSpecRawConfig additions
TypeSpecConfig additions
JSON Schema addition
{ "project": { "oneOf": [ { "type": "boolean", "const": true }, { "type": "object", "properties": { "entrypoint": { "type": "string", "description": "Main TypeSpec file for this project, relative to config directory", "default": "main.tsp" } }, "additionalProperties": false } ], "description": "Marks this configuration as a project boundary. When present, the directory containing this file is the project root." } }Open Questions
Q1: Config merging with nested non-project tspconfigs
If a non-project tspconfig exists inside a project boundary (not as a sibling, but in a subdirectory), how should it behave?
Options:
--configorextendsLeaning toward: Option A — non-project tspconfigs inside a project are opt-in via
--configorextends. Simplest mental model.Q2:
--configpointing to a project fileShould
tsp compile --config path/to/tspconfig.yamlbe allowed when the target config is a project file?Options:
--configworks regardless of project field presence--configcan only point to non-project build configsLeaning toward: Option B — prevents confusing cross-project compilation while maintaining flexibility.
Implementation Phases
Phase 1: Project boundary + entrypoint
projectfield to config schema, JSON schema, and TypeScript typesconfig-loader.ts) to parse and normalize project settingsentrypoint-resolver.ts) to check project tspconfig firstentrypoint-resolution.ts) for directoriestsp inittemplates to includeproject: truePhase 2: Build config interaction
extendswith project configs--configflag validation with project boundariesPhase 3: Feature flags
Depends on PR #9886 decisions.
featuresfield to project config schemaMigration / Backward Compatibility
projectcontinue to work identically.project: trueat their own pace.package.jsonexports["typespec"]/tspMainormain.tsp).tsp init: Updated templates should includeproject: truein generated tspconfig.yaml to encourage adoption.