{"slug": "satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference", "title": "satisfies vs Type Annotation: The TypeScript Choice That Changes Inference", "summary": "TypeScript's `satisfies` operator, introduced in version 4.9, allows developers to validate a value against a type without widening the inferred type, preserving literal keys and values. This is particularly useful for config objects, where type annotations discard specific details, while `satisfies` maintains narrow types for safer autocomplete and key checking.", "body_md": "You have a config object. You want the compiler to check it\n\nagainst a known shape, so you reach for a type annotation:\n\n``` js\ntype RouteConfig = Record<string, string>;\n\nconst routes: RouteConfig = {\n  home: \"/\",\n  users: \"/users\",\n  billing: \"/billing\",\n};\n```\n\nThe check works. Add a route whose value is a number and the\n\ncompiler complains. Good. Then you try to read a route by its\n\nkey and the editor goes quiet:\n\n```\nroutes.home;\n// string\n\nroutes.dashboard;\n// string  — no error, even though \"dashboard\"\n//           is not in the object\n```\n\nThe annotation did its job and then erased everything the\n\ncompiler had figured out on its own. `routes`\n\nis now exactly\n\n`Record<string, string>`\n\n. The literal keys are gone. The literal\n\nvalues are gone. You traded inference for a check.\n\n`satisfies`\n\nis the keyword that lets you keep both. It checks\n\nthe value against the type without throwing away the narrow type\n\nthe compiler inferred. It landed in TypeScript 4.9 in late 2022,\n\nand config objects are where it earns its place.\n\nA type annotation is a contract in one direction: the value must\n\nbe assignable to the declared type, and from that point on the\n\nbinding *is* the declared type. The compiler forgets the\n\nspecifics of the literal you wrote.\n\n``` js\nconst colors: Record<string, string> = {\n  primary: \"#1a1a1a\",\n  accent: \"#d97b2b\",\n};\n\ntype Keys = keyof typeof colors;\n// string  — not \"primary\" | \"accent\"\n```\n\n`keyof typeof colors`\n\nis `string`\n\n, because as far as the type\n\nsystem is concerned `colors`\n\nis a `Record<string, string>`\n\nwith\n\narbitrary string keys. The two keys you wrote are an\n\nimplementation detail the annotation discarded.\n\nThat is fine when you want it. A function parameter typed as\n\n`Record<string, string>`\n\nshould accept any such record. But for\n\na config object you read from, the lost detail is the whole\n\npoint of having it in source.\n\n`satisfies`\n\nchecks the expression against a type and then leaves\n\nthe inferred type in place. Same check, no widening.\n\n``` js\nconst colors = {\n  primary: \"#1a1a1a\",\n  accent: \"#d97b2b\",\n} satisfies Record<string, string>;\n\ntype Keys = keyof typeof colors;\n// \"primary\" | \"accent\"\n\ncolors.primary;\n// \"#1a1a1a\"   — the literal, not string\n\ncolors.missing;\n// error: Property 'missing' does not exist\n```\n\nThe constraint `Record<string, string>`\n\nstill runs. Put a\n\nnumber where a string belongs and you get the same error you\n\nwould with an annotation:\n\n``` js\nconst broken = {\n  primary: \"#1a1a1a\",\n  accent: 0xd97b2b,\n} satisfies Record<string, string>;\n// error: Type 'number' is not assignable to type 'string'.\n```\n\nSo you get the guard rail and you keep the narrow type. The key\n\nnames are real. The values are literals. Autocomplete works.\n\nMisspelled lookups fail.\n\nHere is a shape you have written some version of. A map from a\n\nknown set of environments to their settings.\n\n```\ntype EnvConfig = {\n  apiUrl: string;\n  timeoutMs: number;\n  retries: number;\n};\n\nconst config = {\n  dev: { apiUrl: \"http://localhost\", timeoutMs: 1000, retries: 0 },\n  prod: { apiUrl: \"https://api.app\", timeoutMs: 5000, retries: 3 },\n} satisfies Record<string, EnvConfig>;\n```\n\nEach entry is checked against `EnvConfig`\n\n. Forget `retries`\n\nin\n\none of them and the compiler points at that entry. That is the\n\nannotation half of the job, and `satisfies`\n\ndoes it.\n\nNow the part an annotation throws away. Because the inferred\n\ntype survives, you can derive from it:\n\n```\ntype Env = keyof typeof config;\n// \"dev\" | \"prod\"\n\nfunction load(env: Env) {\n  return config[env];\n}\n\nload(\"dev\");    // ok\nload(\"staging\"); // error: not \"dev\" | \"prod\"\n```\n\n`Env`\n\nis the union of the actual keys. Had you annotated\n\n`config`\n\nas `Record<string, EnvConfig>`\n\n, `keyof typeof config`\n\nwould be `string`\n\nand `load(\"staging\")`\n\nwould compile and blow\n\nup at runtime when it indexed an object that has no `staging`\n\nkey. The narrow type the compiler kept is what makes the lookup\n\nsafe.\n\n`satisfies`\n\nis not a replacement for annotations. They answer\n\ndifferent questions.\n\nAn annotation says: *this binding has this type, treat it that\nway everywhere.* That is what you want for a public API surface,\n\n``` js\nexport const defaults: Settings = {\n  theme: \"dark\",\n  fontSize: 14,\n};\n```\n\nHere you want `defaults`\n\nto be `Settings`\n\n, not the narrow shape.\n\nA consumer should see the documented type, and you should be\n\nfree to reassign or build `defaults`\n\ndifferently later without\n\nthe inferred type rippling out. The annotation is the right\n\ncall.\n\nAnnotations also catch a class of error `satisfies`\n\ndoes not:\n\nthe missing property at the point of declaration.\n\n``` js\nconst a: Settings = { theme: \"dark\" };\n// error: Property 'fontSize' is missing\n\nconst b = { theme: \"dark\" } satisfies Settings;\n// error: Property 'fontSize' is missing\n```\n\nBoth flag it here, so that is a wash. The real split is\n\ndirection. An annotation forces the value down to the type.\n\n`satisfies`\n\nchecks the value against the type and keeps the\n\nvalue's own type. When you read from the binding, you almost\n\nalways want the second. When you hand the binding to someone\n\nelse as a typed contract, you usually want the first.\n\nYou can use both when you want a checked, narrow value behind a\n\ndeclared contract. The order reads outside-in: annotate the\n\nexported name, build it with `satisfies`\n\nso the internal lookups\n\nstay sharp.\n\n``` js\nconst internalRoutes = {\n  home: \"/\",\n  users: \"/users\",\n} satisfies Record<string, string>;\n\n// internal code gets \"home\" | \"users\" autocomplete\ninternalRoutes.home;\n\n// exported surface is the wide, documented type\nexport const routes: Record<string, string> = internalRoutes;\n```\n\nInside the module you keep the literal keys. The export presents\n\nthe contract. Two bindings, two jobs, no lost information in\n\neither.\n\n`satisfies`\n\nchecks excess properties the way an object literal\n\ndoes, which can surprise you when the constraint is a union.\n\n```\ntype Shape =\n  | { kind: \"circle\"; r: number }\n  | { kind: \"square\"; side: number };\n\nconst s = {\n  kind: \"circle\",\n  r: 10,\n  side: 4,\n} satisfies Shape;\n// error: Object literal may only specify known\n//        properties, and 'side' does not exist in\n//        type ...\n```\n\nThe annotation form rejects this too, so the behavior is\n\nconsistent. The thing to remember is that `satisfies`\n\ndoes not\n\nloosen any check. It only changes what type the binding ends up\n\nwith. If a value fails an annotation, it fails `satisfies`\n\nwith\n\nthe same message.\n\nReach for `satisfies`\n\nwhen you read from the value and want the\n\ncompiler to remember the specifics: config maps, route tables,\n\nlookup objects, anything where `keyof typeof`\n\nor a literal value\n\ntype is useful downstream. Reach for an annotation when the\n\ndeclared type is the contract you are exposing and the literal\n\nis just today's filling.\n\nThe mistake is using an annotation on a config object you read\n\nfrom, watching autocomplete go dark, and assuming that is the\n\ncost of type safety. It is not. `satisfies`\n\ngives you the check\n\nand keeps the inference. On a config object, that is the whole\n\ngame.\n\nIf the difference between widening and constraint inference is\n\nthe kind of distinction you want laid out in full — generics,\n\nconditional and mapped types, `infer`\n\n, the machinery `satisfies`\n\nsits on top of — that is what *The TypeScript Type System* digs\n\ninto. The config-object patterns here are the shallow end of it.\n\n**The TypeScript Library — the 5-book collection.** Books 1 and 2 are the core path; 3 and 4 substitute for 1 and 2 if you come from the JVM or PHP; book 5 is for anyone shipping TS at work.\n\n*All five books ship in ebook, paperback, and hardcover.*", "url": "https://wpnews.pro/news/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference", "canonical_source": "https://dev.to/gabrielanhaia/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference-lgn", "published_at": "2026-06-13 10:05:46+00:00", "updated_at": "2026-06-13 10:17:29.379896+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["TypeScript"], "alternates": {"html": "https://wpnews.pro/news/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference", "markdown": "https://wpnews.pro/news/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference.md", "text": "https://wpnews.pro/news/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference.txt", "jsonld": "https://wpnews.pro/news/satisfies-vs-type-annotation-the-typescript-choice-that-changes-inference.jsonld"}}