Skip to content

Commit 474a556

Browse files
author
Batiste Bieler
committed
Add mapped types
1 parent f2c1c44 commit 474a556

30 files changed

Lines changed: 2647 additions & 1111 deletions

src/constants.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,19 @@ const BUILTIN_TYPES = new Set([
205205

206206
// Legacy/alias types
207207
'int',
208+
209+
// Built-in utility generic types (TypeScript-compatible)
210+
'Record',
211+
'Partial',
212+
'Required',
213+
'Readonly',
214+
'Pick',
215+
'Omit',
216+
'Exclude',
217+
'Extract',
218+
'NonNullable',
219+
'ReturnType',
220+
'Parameters',
208221
]);
209222

210223
/**

src/grammar.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,14 @@ const grammar = {
107107
['readonly:readonly', 'type_primary:inner'],
108108
['keyof', 'type_primary:subject', 'array_suffix?'],
109109
['tuple_type:tuple', 'array_suffix?'],
110+
['mapped_type:mapped'],
110111
['object_type', 'array_suffix?'],
111112
['str:literal', 'array_suffix?'],
112113
['number:literal', 'array_suffix?'],
113114
['type_name:name', '<', 'type_arg_list:type_args', '>', 'array_suffix?'],
114115
['type_name:name', '<', 'type_arg_list:type_args', '>'],
115116
['type_name:name', '.', 'name:member', 'array_suffix?'],
117+
['type_name:name', '[', 'name:index_key', ']', 'array_suffix?'],
116118
['type_name:name', '[', 'str:member_key', ']', 'array_suffix?'],
117119
['type_name:name', 'array_suffix?'],
118120
],
@@ -139,6 +141,10 @@ const grammar = {
139141
['{', 'single_space_or_newline', 'object_type_properties:properties', 'single_space_or_newline', '}'],
140142
['{', '}'],
141143
],
144+
'mapped_type': [
145+
['{', 'single_space_or_newline', 'readonly:readonly', 'w', '[', 'name:key_param', 'w', 'in', 'type_expression:source', ']', 'question?:optional', 'colon', 'w?', 'type_expression:value', 'single_space_or_newline', '}'],
146+
['{', 'single_space_or_newline', '[', 'name:key_param', 'w', 'in', 'type_expression:source', ']', 'question?:optional', 'colon', 'w?', 'type_expression:value', 'single_space_or_newline', '}'],
147+
],
142148
'object_type_properties': [
143149
['object_type_property', ',', 'single_space_or_newline', 'object_type_properties'],
144150
['object_type_property', ','],

src/inference/Type.js

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -354,7 +354,7 @@ export class ObjectType extends Type {
354354
}
355355

356356
// Resolve aliases and type member access
357-
if (target instanceof TypeAlias || target instanceof TypeMemberAccess || target instanceof KeyofType) {
357+
if (target instanceof TypeAlias || target instanceof TypeMemberAccess || target instanceof KeyofType || target instanceof MappedType || target instanceof TypeIndexAccess) {
358358
const resolved = aliases.resolve(target);
359359
// Avoid infinite recursion if alias can't be resolved
360360
if (resolved === target) return false;
@@ -1023,6 +1023,85 @@ export class TypeMemberAccess extends Type {
10231023
}
10241024
}
10251025

1026+
/**
1027+
* Mapped type: { [K in source]: valueType }
1028+
* Represents a mapped object type that iterates over keys of a source type.
1029+
* When resolved, expands to an ObjectType with one property per key.
1030+
*/
1031+
export class MappedType extends Type {
1032+
/**
1033+
* @param {string} keyParam - The name of the key parameter (e.g. 'K')
1034+
* @param {Type} sourceType - The source type whose keys to iterate (e.g. KeyofType)
1035+
* @param {Type} valueType - The value type expression (may reference keyParam)
1036+
* @param {boolean} optional - Whether properties should be optional
1037+
* @param {boolean} readonly - Whether properties should be readonly
1038+
*/
1039+
constructor(keyParam, sourceType, valueType, optional = false, readonly = false) {
1040+
super();
1041+
this.kind = 'mapped';
1042+
this.keyParam = keyParam;
1043+
this.sourceType = sourceType;
1044+
this.valueType = valueType;
1045+
this.optional = optional;
1046+
this.readonly = readonly;
1047+
}
1048+
1049+
toString() {
1050+
const ro = this.readonly ? 'readonly ' : '';
1051+
const opt = this.optional ? '?' : '';
1052+
return `{ ${ro}[${this.keyParam} in ${this.sourceType}]${opt}: ${this.valueType} }`;
1053+
}
1054+
1055+
equals(other) {
1056+
return other instanceof MappedType
1057+
&& this.keyParam === other.keyParam
1058+
&& this.sourceType.equals(other.sourceType)
1059+
&& this.valueType.equals(other.valueType)
1060+
&& this.optional === other.optional;
1061+
}
1062+
1063+
isCompatibleWith(target, aliases) {
1064+
const resolved = aliases.resolve(this);
1065+
if (resolved !== this) return resolved.isCompatibleWith(target, aliases);
1066+
if (isAnyType(target)) return true;
1067+
return this.equals(target);
1068+
}
1069+
}
1070+
1071+
/**
1072+
* Indexed type access: T[K] where K is a type variable or literal.
1073+
* Used in mapped type value expressions like T[K].
1074+
*/
1075+
export class TypeIndexAccess extends Type {
1076+
/**
1077+
* @param {Type} baseType - The type being indexed (e.g. TypeAlias('T'))
1078+
* @param {Type} keyType - The key type (e.g. TypeAlias('K') or LiteralType)
1079+
*/
1080+
constructor(baseType, keyType) {
1081+
super();
1082+
this.kind = 'index_access';
1083+
this.baseType = baseType;
1084+
this.keyType = keyType;
1085+
}
1086+
1087+
toString() {
1088+
return `${this.baseType}[${this.keyType}]`;
1089+
}
1090+
1091+
equals(other) {
1092+
return other instanceof TypeIndexAccess
1093+
&& this.baseType.equals(other.baseType)
1094+
&& this.keyType.equals(other.keyType);
1095+
}
1096+
1097+
isCompatibleWith(target, aliases) {
1098+
const resolved = aliases.resolve(this);
1099+
if (resolved !== this) return resolved.isCompatibleWith(target, aliases);
1100+
if (isAnyType(target)) return true;
1101+
return this.equals(target);
1102+
}
1103+
}
1104+
10261105
/**
10271106
* Type alias reference (not resolved yet)
10281107
*/
@@ -1157,6 +1236,59 @@ export class TypeAliasMap {
11571236
return StringType;
11581237
}
11591238

1239+
/**
1240+
* Resolve a TypeIndexAccess T[K] to the concrete property type.
1241+
* If K is a string literal and T is an ObjectType, looks up the property.
1242+
* @param {TypeIndexAccess} type
1243+
* @returns {Type}
1244+
*/
1245+
resolveIndexAccess(type) {
1246+
const resolvedBase = this.resolve(type.baseType);
1247+
const resolvedKey = this.resolve(type.keyType);
1248+
if (resolvedBase instanceof ObjectType && resolvedKey instanceof LiteralType) {
1249+
const prop = resolvedBase.properties.get(resolvedKey.value);
1250+
if (prop) return prop.type;
1251+
}
1252+
if (resolvedBase instanceof PrimitiveType && resolvedBase.name === 'any') return AnyType;
1253+
return AnyType;
1254+
}
1255+
1256+
/**
1257+
* Resolve a MappedType by expanding its key set and building an ObjectType.
1258+
* @param {MappedType} type
1259+
* @returns {Type}
1260+
*/
1261+
resolveMappedType(type) {
1262+
const resolvedSource = this.resolve(type.sourceType);
1263+
1264+
// Collect the concrete key literals
1265+
let keyLiterals;
1266+
if (resolvedSource instanceof UnionType) {
1267+
keyLiterals = resolvedSource.types.filter(t => t instanceof LiteralType && t.baseType === StringType);
1268+
} else if (resolvedSource instanceof LiteralType && resolvedSource.baseType === StringType) {
1269+
keyLiterals = [resolvedSource];
1270+
} else if (resolvedSource instanceof PrimitiveType && resolvedSource.name === 'string') {
1271+
// Open key set — produce a RecordType
1272+
const subs = new Map([[type.keyParam, StringType]]);
1273+
const resolvedValue = this.resolve(substituteTypeParams(type.valueType, subs));
1274+
return new RecordType(StringType, resolvedValue);
1275+
} else {
1276+
// Cannot determine keys — fallback
1277+
return AnyType;
1278+
}
1279+
1280+
if (keyLiterals.length === 0) return new ObjectType(new Map());
1281+
1282+
const newProps = new Map();
1283+
for (const keyLiteral of keyLiterals) {
1284+
const subs = new Map([[type.keyParam, keyLiteral]]);
1285+
const substituted = substituteTypeParams(type.valueType, subs);
1286+
const resolvedValue = this.resolve(substituted);
1287+
newProps.set(String(keyLiteral.value), { type: resolvedValue, optional: type.optional, readonly: type.readonly });
1288+
}
1289+
return new ObjectType(newProps);
1290+
}
1291+
11601292
/**
11611293
* Resolve a type alias (recursive with cycle detection)
11621294
* @param {Type} type
@@ -1169,6 +1301,12 @@ export class TypeAliasMap {
11691301
if (type instanceof KeyofType) {
11701302
return this.resolveKeyof(type);
11711303
}
1304+
if (type instanceof TypeIndexAccess) {
1305+
return this.resolveIndexAccess(type);
1306+
}
1307+
if (type instanceof MappedType) {
1308+
return this.resolveMappedType(type);
1309+
}
11721310
if (!(type instanceof TypeAlias)) {
11731311
return type;
11741312
}
@@ -1307,6 +1445,26 @@ export function substituteTypeParams(type, substitutions) {
13071445
return new KeyofType(substituteTypeParams(type.subjectType, substitutions));
13081446
}
13091447

1448+
if (type instanceof MappedType) {
1449+
// Substitute in sourceType and valueType, but NOT in keyParam (it's a bound variable)
1450+
const filteredSubs = new Map(substitutions);
1451+
filteredSubs.delete(type.keyParam); // keyParam is locally bound — don't substitute it
1452+
return new MappedType(
1453+
type.keyParam,
1454+
substituteTypeParams(type.sourceType, substitutions),
1455+
substituteTypeParams(type.valueType, filteredSubs),
1456+
type.optional,
1457+
type.readonly
1458+
);
1459+
}
1460+
1461+
if (type instanceof TypeIndexAccess) {
1462+
return new TypeIndexAccess(
1463+
substituteTypeParams(type.baseType, substitutions),
1464+
substituteTypeParams(type.keyType, substitutions)
1465+
);
1466+
}
1467+
13101468
// Primitives and literals don't need substitution
13111469
return type;
13121470
}

src/inference/handlers/expressions.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,10 @@ function handleExpObjAccess(node, parent, getState) {
6969
function handleSimpleVariable(name, parent, definition, getState) {
7070
const { pushInference, typeAliases, pushWarning, inferencePhase } = getState();
7171

72-
// Exhaustiveness check: if a variable has been narrowed to `never` by the type
73-
// guard system (i.e. all union cases have been handled via early-return guards),
74-
// reading it is dead code — all possible values have already been accounted for.
75-
if (inferencePhase === 'checking' && definition?.narrowed && definition.type === NeverType) {
72+
// Exhaustiveness check: if a variable has been marked as exhausted by applyPostIfGuard
73+
// (i.e. all union cases have been handled via prior early-return guards), reading it is
74+
// dead code — all possible values have already been accounted for.
75+
if (inferencePhase === 'checking' && definition?.__exhausted) {
7676
pushWarning(name, `'${name.value}' has been narrowed to 'never' — this code is unreachable: all union cases are already handled above`);
7777
}
7878

src/inference/typeGuards.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,8 @@ function applyElseBranchGuard(scope, typeGuard, lookupVariable, typeAliases) {
553553
/**
554554
* Apply the correct scope effect to the outer scope after an early-return if guard.
555555
* Same semantics as applyElseBranchGuard: code past the if block is the "false" path.
556+
* When the result of the exclusion is `never`, the variable is marked as `__exhausted`
557+
* so that the exhaustiveness checker can warn on subsequent uses.
556558
* @param {Object} typeAliases - Required when typeGuard.property is set
557559
*/
558560
function applyPostIfGuard(scope, typeGuard, lookupVariable, typeAliases) {
@@ -577,6 +579,11 @@ function applyPostIfGuard(scope, typeGuard, lookupVariable, typeAliases) {
577579
} else {
578580
applyExclusion(scope, typeGuard.variable, typeGuard.checkType, lookupVariable);
579581
}
582+
// Mark as exhausted if exclusion reduced the type to `never` — enables exhaustiveness warnings
583+
const afterDef = scope[typeGuard.variable];
584+
if (afterDef && afterDef.type?.kind === 'primitive' && afterDef.type?.name === 'never') {
585+
afterDef.__exhausted = true;
586+
}
580587
}
581588

582589
/**

src/inference/typeParser.js

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
Type, Types, TypeAlias, TypeMemberAccess, KeyofType, UnionType, IntersectionType, ArrayType, ObjectType,
77
TupleType, GenericType, LiteralType, PrimitiveType, PredicateType,
88
StringType, NumberType, BooleanType, NullType, UndefinedType,
9-
AnyFunctionType, FunctionType
9+
AnyFunctionType, FunctionType, MappedType, TypeIndexAccess
1010
} from './Type.js';
1111

1212
/**
@@ -129,6 +129,11 @@ export function parseTypePrimary(typePrimaryNode) {
129129
return baseType;
130130
}
131131

132+
// Check for mapped type: { [K in source]: value }
133+
if (typePrimaryNode.named.mapped) {
134+
return parseMappedType(typePrimaryNode.named.mapped);
135+
}
136+
132137
// Check for readonly type: readonly T[]
133138
if (typePrimaryNode.named.readonly) {
134139
const innerType = parseTypePrimary(typePrimaryNode.named.inner);
@@ -156,7 +161,7 @@ export function parseTypePrimary(typePrimaryNode) {
156161
const value = parseFloat(typePrimaryNode.named.literal.value);
157162
baseType = Types.literal(value, NumberType);
158163
} else {
159-
const { name, type_args, member, member_key } = typePrimaryNode.named;
164+
const { name, type_args, member, member_key, index_key } = typePrimaryNode.named;
160165
if (name) {
161166
// name is a type_name node, get its first child
162167
const typeToken = name.children ? name.children[0] : name;
@@ -169,6 +174,11 @@ export function parseTypePrimary(typePrimaryNode) {
169174
: member_key.value.slice(1, -1); // strip surrounding quotes
170175
baseType = new TypeMemberAccess(new TypeAlias(typeName), key);
171176
} else
177+
// Check for index type access: T[K] where K is a type variable name
178+
if (index_key) {
179+
const keyName = index_key.value ?? (index_key.children?.[0]?.value);
180+
baseType = new TypeIndexAccess(new TypeAlias(typeName), new TypeAlias(keyName));
181+
} else
172182
// Check if it's a generic type instantiation: Type<Args>
173183
if (type_args) {
174184
const typeArgs = parseTypeArguments(type_args);
@@ -208,6 +218,21 @@ function parseTupleTypeElements(node) {
208218
return types;
209219
}
210220

221+
/**
222+
* Parse a mapped_type AST node into a MappedType
223+
* Grammar: { [K in source]: value } or { readonly [K in source]?: value }
224+
* @param {Object} mappedTypeNode - The mapped_type AST node
225+
* @returns {MappedType}
226+
*/
227+
function parseMappedType(mappedTypeNode) {
228+
if (!mappedTypeNode || !mappedTypeNode.named) return Types.any;
229+
const { key_param, source, value, optional, readonly } = mappedTypeNode.named;
230+
const keyParam = key_param?.value ?? 'K';
231+
const sourceType = parseTypeExpression(source);
232+
const valueType = parseTypeExpression(value);
233+
return new MappedType(keyParam, sourceType, valueType, !!optional, !!readonly);
234+
}
235+
211236
/**
212237
* Parse an array_suffix node to handle one or more [] pairs
213238
* Multi-dimensional arrays recursively wrap the type

0 commit comments

Comments
 (0)