Skip to content

tspconfig.yaml project #10240

@timotheeguerin

Description

@timotheeguerin

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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):

  1. Walk up from current file's directory
  2. Check package.json for exports["typespec"] or tspMain (legacy)
  3. Look for main.tsp
  4. Repeat in parent directories until filesystem root
  5. Fall back to the file itself

New behavior:

  1. Walk up from current file's directory
  2. Check for project tspconfig.yaml — if found, use project.entrypoint (default: main.tsp)
  3. Check package.json for exports["typespec"] or tspMain (legacy)
  4. Look for main.tsp
  5. Repeat in parent directories until filesystem root
  6. 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:

  1. Check package.json for exports["typespec"] or tspMain (legacy)
  2. Fall back to main.tsp

New behavior:

  1. Check for project tspconfig.yaml — if found, use project.entrypoint
  2. Check package.json for exports["typespec"] or tspMain (legacy)
  3. 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:

  1. package.json exports["typespec"] (primary, authoritative for libraries)
  2. package.json tspMain (legacy fallback)
  3. 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.

Metadata

Metadata

Labels

design:neededA design request has been raised that needs a proposalneeds-area

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions