openlang

Annotation or satisfies for a config object

For config objects I've mostly moved from const config: Config = {...} to {...} satisfies Config. With the annotation, config.mode reads back as 'light' | 'dark' even though I wrote 'dark'. With satisfies, the object is checked against Config and the variable keeps the literal types it was written with, so config.mode is 'dark', and comparing it to 'light' later is a type error instead of a dead branch.

Both forms still flag a misspelled property on the literal. The 4.9 notes show it with a bleu key checked against Record<Colors, string | RGB>.

Where it fooled me was a routes table written as satisfies Record<string, Route>. A misspelled route name isn't an excess property there. Every string key is allowed, so the check passes and the typo turns into a route nobody links to. The annotation has the same hole. satisfies just made me feel more checked than I was.

For a fixed set of keys I now spell the union out, Record<RouteName, Route>, and let the object fail when it grows a stranger.

The conversation continues 5

Follow the reasoning
  1. Permalink

    One more difference, on optional properties. With the annotation, a timeout?: number you left out still reads as number | undefined, and every reader has to decide what undefined means. With satisfies, the property isn't in the inferred type at all, so config.timeout fails with TS2339, property does not exist.

    That is a sharper answer and a different one. Add timeout to the literal later and every reader that was rejected starts compiling. Pick by whether callers should handle a missing value or never see one.

  2. Permalink

    For anything exported I'd go the other way. A satisfies object exports its literal type too, so every module importing config now depends on the values inside it. Change 'dark' to 'light' and a comparison three files away stops compiling. That code was relying on a value, not on a type, and nobody decided it should. For application config I still prefer the annotation. Config says what the code may rely on, nothing more.

  3. Permalink

    The root makes this sound simpler than it is. satisfies doesn't keep every literal you write.

    type Config = { mode: 'light' | 'dark'; port: number };
    
    const config = { mode: 'dark', port: 8080 } satisfies Config;
    
    const m: 'dark' = config.mode;  // ok
    const p: 8080 = config.port;    // error, port is number

    port widens to number because Config gives it number as the contextual type. mode stays 'dark' only because Config declares a union of string literals. So it keeps the more specific inferred type, and how specific depends on Config. The worry about importers leaning on values applies to mode and mostly not to port.

  4. Permalink

    So the user opens settings, picks the dark theme, and that choice gets written to a JSON file. Next launch the app reads that file back, and satisfies was never anywhere near it (it only ever looked at the literal in the source). The literal config says 'dark', the saved file might say 'Dark' or nothing at all. Do people keep the satisfies object as the defaults and parse the saved file into Config with a runtime check, or is there a way to keep the two from drifting that I'm missing?

  5. Permalink

    Answering my own question after trying it. The satisfies literal became defaults, the saved file goes through a small parseSettings(raw: unknown): Partial<Config>, and the app runs on const config: Config = { ...defaults, ...saved }. The annotation on the merged value also takes care of selim70's worry, since importers only see Config.

    The part that bit me was the parser. My first version copied every known key and set it to undefined when the value was junk. Spreading an explicit undefined wipes the default, and the types didn't object (no exactOptionalPropertyTypes in that project). So a user who typed "Dark" by hand got neither dark nor light, just a theme picker with nothing selected. Now the parser leaves a bad key out entirely, and the settings screen shows a small note that one saved value was ignored.

Other sides of the same question

  1. Units of measure as cheap ids

    For integer ids I've been trying units of measure instead of single-case unions, and I'd like a second opinion before it spre…

    Replies 1fsharp
  2. A helper ported from Java picks a different overload in Groovy

    Moved a Java helper class into Groovy on the build script side, and the same call went to a different method. Laid out in ord…

    Replies 3groovyjava
  3. An empty cell equals zero and the empty string

    Inherited a macro that flags order rows with a zero quantity, and it flagged every blank row too. The check was If ws.Cells(r…

    Replies 7vba